From c3812be8065b0bf49de4f966fe94f46da34f6ecc Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 22 Apr 2025 16:21:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(markdown):=20enhance=20Markdown=20component?= =?UTF-8?q?=20with=20middleware=20support=EF=BC=8Cfix=20ai=20summary=20ent?= =?UTF-8?q?ry=20id=20renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated the Markdown component to accept an `applyMiddleware` prop for custom processing. - Modified the `parseMarkdown` function to utilize the new middleware feature, allowing for additional processing steps. - Adjusted related components to ensure compatibility with the new middleware functionality. These changes improve the flexibility and extensibility of the Markdown rendering process. Signed-off-by: Innei --- .../src/components/ui/markdown/Markdown.tsx | 4 +- .../src/components/ui/media/preview-media.tsx | 4 +- .../src/modules/ai/ai-daily/daily.tsx | 140 ++++++++++++------ .../ai/ai-daily/plugins/parse-snowflake.ts | 82 ++++++++++ .../components/src/utils/parse-markdown.tsx | 13 +- 5 files changed, 189 insertions(+), 54 deletions(-) create mode 100644 apps/desktop/src/renderer/src/modules/ai/ai-daily/plugins/parse-snowflake.ts diff --git a/apps/desktop/src/renderer/src/components/ui/markdown/Markdown.tsx b/apps/desktop/src/renderer/src/components/ui/markdown/Markdown.tsx index 7f0e98171..d82cfe7f8 100644 --- a/apps/desktop/src/renderer/src/components/ui/markdown/Markdown.tsx +++ b/apps/desktop/src/renderer/src/components/ui/markdown/Markdown.tsx @@ -10,8 +10,8 @@ export const Markdown: Component< { children: string } & Partial -> = ({ children, components, className }) => { - const stableRemarkOptions = useState({ components })[0] +> = ({ children, components, className, applyMiddleware }) => { + const stableRemarkOptions = useState({ components, applyMiddleware })[0] const markdownElement = useMemo( () => parseMarkdown(children, { ...stableRemarkOptions }).content, diff --git a/apps/desktop/src/renderer/src/components/ui/media/preview-media.tsx b/apps/desktop/src/renderer/src/components/ui/media/preview-media.tsx index bce556c3f..dec76953d 100644 --- a/apps/desktop/src/renderer/src/components/ui/media/preview-media.tsx +++ b/apps/desktop/src/renderer/src/components/ui/media/preview-media.tsx @@ -103,8 +103,8 @@ const Wrapper: Component<{ "flex justify-end gap-3 p-2 text-white/70 duration-200 [&_button]:hover:text-white", "hover:!transform-none hover:!opacity-100", - sideContent ? "rounded-bl-xl" : "rounded-xl", - "bg-black/30", + // sideContent ? "rounded-bl-xl" : "rounded-xl", + "bg-black/50", )} onClick={stopPropagation} > diff --git a/apps/desktop/src/renderer/src/modules/ai/ai-daily/daily.tsx b/apps/desktop/src/renderer/src/modules/ai/ai-daily/daily.tsx index f445a416f..edd48df4f 100644 --- a/apps/desktop/src/renderer/src/modules/ai/ai-daily/daily.tsx +++ b/apps/desktop/src/renderer/src/modules/ai/ai-daily/daily.tsx @@ -16,7 +16,7 @@ import type { Components } from "hast-util-to-jsx-runtime" import type { Variant } from "motion/react" import { m, useAnimationControls } from "motion/react" import type { FC } from "react" -import { useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { Trans, useTranslation } from "react-i18next" import { MenuItemText } from "~/atoms/context-menu" @@ -53,6 +53,7 @@ import { Queries } from "~/queries" import { useEntry } from "~/store/entry" import { useFeedById } from "~/store/feed" +import { remarkSnowflakeId } from "./plugins/parse-snowflake" import type { DailyItemProps, DailyView } from "./types" import { useParseDailyDate } from "./useParseDailyDate" @@ -176,17 +177,28 @@ export const DailyReportContent: Component = ({ const content = useQueryData({ endDate, startDate, view }) const RelatedEntryLink = useState(() => createRelatedEntryLink("modal"))[0] + return ( - + {content.isLoading ? ( - + ) : ( !!content.data && ( { + pipeline.use(remarkSnowflakeId) + + return pipeline + }} components={{ + // @ts-expect-error + "snowflake-id": SnowflakeId, a: RelatedEntryLink as Components["a"], }} className="prose-sm prose-p:my-1 prose-ul:my-1 prose-ul:list-outside prose-ul:list-disc prose-li:marker:text-accent mt-4 px-6" @@ -265,8 +277,7 @@ const createRelatedEntryLink = (variant: "toast" | "modal") => (props: LinkProps const { href, children } = props const entryId = isBizId(href) ? href : null - const { present } = useModalStack() - + const peekModal = usePeekModal() if (!entryId) { return } @@ -275,49 +286,7 @@ const createRelatedEntryLink = (variant: "toast" | "modal") => (props: LinkProps type="button" className="follow-link--underline text-foreground cursor-pointer font-semibold no-underline" onClick={() => { - const basePresentProps = { - clickOutsideToDismiss: true, - title: "Entry Preview", - } - - if (variant === "toast") { - present({ - ...basePresentProps, - CustomModalComponent: PlainModal, - content: () => , - overlay: false, - modal: false, - modalContainerClassName: "right-0 left-[auto]", - }) - } else { - present({ - ...basePresentProps, - autoFocus: false, - modalClassName: - "relative mx-auto mt-[10vh] scrollbar-none max-w-full overflow-auto px-2 lg:max-w-[65rem] lg:p-0", - // eslint-disable-next-line @eslint-react/no-nested-component-definitions - CustomModalComponent: ({ children }) => { - const { feedId } = useEntry(entryId) || {} - - return ( - {}, - label: "More Actions", - icon: , - }, - ]} - to={`/timeline/view-${getRouteParams().view}/${feedId}/${entryId}`} - > - {children} - - ) - }, - content: () => , - overlay: true, - }) - } + peekModal(entryId, variant) }} > {children} @@ -326,6 +295,57 @@ const createRelatedEntryLink = (variant: "toast" | "modal") => (props: LinkProps ) } +const usePeekModal = () => { + const { present } = useModalStack() + return useCallback( + (entryId: string, variant: "toast" | "modal") => { + const basePresentProps = { + clickOutsideToDismiss: true, + title: "Entry Preview", + } + + if (variant === "toast") { + present({ + ...basePresentProps, + CustomModalComponent: PlainModal, + content: () => , + overlay: false, + modal: false, + modalContainerClassName: "right-0 left-[auto]", + }) + } else { + present({ + ...basePresentProps, + autoFocus: false, + modalClassName: + "relative mx-auto mt-[10vh] scrollbar-none max-w-full overflow-auto px-2 lg:max-w-[65rem] lg:p-0", + + CustomModalComponent: ({ children }) => { + const { feedId } = useEntry(entryId) || {} + + return ( + {}, + label: "More Actions", + icon: , + }, + ]} + to={`/timeline/view-${getRouteParams().view}/${feedId}/${entryId}`} + > + {children} + + ) + }, + content: () => , + overlay: true, + }) + } + }, + [present], + ) +} const EntryToastPreview = ({ entryId }: { entryId: string }) => { useAuthQuery(Queries.entries.byId(entryId)) @@ -508,3 +528,27 @@ const EntryMoreActions: FC<{ entryId: string }> = ({ entryId }) => { ) } + +interface SnowflakeIdProps { + id: string + children?: React.ReactNode + index: number +} + +const SnowflakeId: React.FC = ({ id: entryId, index }) => { + const peekModal = usePeekModal() + + return ( + + ) +} diff --git a/apps/desktop/src/renderer/src/modules/ai/ai-daily/plugins/parse-snowflake.ts b/apps/desktop/src/renderer/src/modules/ai/ai-daily/plugins/parse-snowflake.ts new file mode 100644 index 000000000..d7c97f64e --- /dev/null +++ b/apps/desktop/src/renderer/src/modules/ai/ai-daily/plugins/parse-snowflake.ts @@ -0,0 +1,82 @@ +import type { Parent } from "mdast" +import type { Node } from "unist" +import { visit } from "unist-util-visit" + +interface SnowflakeOptions { + component?: string +} + +const SNOWFLAKE_PATTERN = /\[(\d{15,20})\]/g + +function isTextNode(node: Node): node is { type: "text"; value: string } { + return node.type === "text" +} + +export function remarkSnowflakeId(options: SnowflakeOptions = {}) { + const { component = "snowflake-id" } = options + + return (tree: Node) => { + let idIndex = 1 + visit(tree, "text", (node, index, parent: Parent) => { + if (!isTextNode(node) || !parent) return + + const { value } = node as { value: string } + const matches = Array.from(value.matchAll(SNOWFLAKE_PATTERN)) + + if (matches.length === 0) return + + const result: (Node | string)[] = [] + let lastIndex = 0 + + for (const match of matches) { + if (!Array.isArray(match)) continue + + const [fullMatch, snowflakeId] = match + const matchIndex = match.index! + + // Add text before the match + if (matchIndex > lastIndex) { + result.push({ + type: "text", + // @ts-expect-error + value: value.slice(lastIndex, matchIndex), + }) + } + + // Add snowflake node + result.push({ + type: "snowflakeId", + data: { + hName: component, + hProperties: { + id: snowflakeId, + index: idIndex++, + }, + }, + // @ts-expect-error + children: [ + { + type: "text", + value: fullMatch, + }, + ], + }) + + lastIndex = matchIndex + fullMatch.length + } + + // Add any remaining text after the last match + if (lastIndex < value.length) { + result.push({ + type: "text", + // @ts-expect-error + value: value.slice(lastIndex), + }) + } + + // Replace the current node with the new nodes + // @ts-expect-error + parent.children.splice(index, 1, ...result) + }) + } +} diff --git a/packages/components/src/utils/parse-markdown.tsx b/packages/components/src/utils/parse-markdown.tsx index 7703d42ce..45f978e9f 100644 --- a/packages/components/src/utils/parse-markdown.tsx +++ b/packages/components/src/utils/parse-markdown.tsx @@ -14,17 +14,20 @@ import remarkGfm from "remark-gfm" import remarkGithubAlerts from "remark-gh-alerts" import remarkParse from "remark-parse" import remarkRehype from "remark-rehype" +import type { Processor } from "unified" import { unified } from "unified" import { VFile } from "vfile" export interface RemarkOptions { components: Partial + applyMiddleware?: >(pipeline: T) => T } + export const parseMarkdown = (content: string, options?: Partial) => { const file = new VFile(content) - const { components } = options || {} + const { components, applyMiddleware } = options || {} - const pipeline = unified() + let pipeline: Processor = unified() .use(remarkDirective) .use(remarkParse) @@ -61,6 +64,12 @@ export const parseMarkdown = (content: string, options?: Partial) }, }) + // Apply custom middleware if provided + if (applyMiddleware) { + pipeline = applyMiddleware(pipeline) + } + + pipeline = pipeline .use(remarkRehype, { allowDangerousHtml: true }) .use(rehypeStringify, { allowDangerousHtml: true })