Files
Jyotisha/frontend/src/components/personal-report/personal-report-markdown-view.tsx
T

218 lines
6.9 KiB
TypeScript

"use client";
import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { fencedLanguage, fencedSource } from "@/components/markdown-code-block";
import {
chartAriaLabel,
parseReportChartBlock,
toNorthIndianChart,
} from "@/lib/report-chart-block";
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
import {
buildLongformOutline,
type LongformHeading,
type LongformSection,
} from "@/lib/personal-report-longform-outline";
function flattenText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(flattenText).join("");
if (node && typeof node === "object" && "props" in node) {
const props = (node as { props?: { children?: ReactNode } }).props;
return flattenText(props?.children);
}
return "";
}
function reportUrlTransform(url: string): string {
return url.startsWith("#") ? url : "";
}
function markdownComponents(headings: readonly LongformHeading[]): Components {
const cursor = { h2: 0, h3: 0 };
const nextId = (level: 2 | 3, children: ReactNode) => {
const title = flattenText(children).trim();
const key = level === 2 ? "h2" : "h3";
const matches = headings.filter((heading) => heading.level === level && heading.title === title);
const index = cursor[key];
cursor[key] = index + 1;
return matches[index]?.id ?? matches[0]?.id;
};
return {
a: ({ children, href }) => {
if (typeof href === "string" && href.startsWith("#")) {
return <a href={href}>{children}</a>;
}
return <span>{children}</span>;
},
img: () => null,
table: ({ children }) => (
<div className="markdown-table personal-report-table-wrap">
<table>{children}</table>
</div>
),
h2: ({ children }) => <h2 id={nextId(2, children)}>{children}</h2>,
h3: ({ children }) => <h3 id={nextId(3, children)}>{children}</h3>,
h4: ({ children }) => {
const title = flattenText(children).trim();
const isChart = /^(?:D\d{1,3}\b|Moon Chart)/.test(title);
return (
<h4 className={isChart ? "personal-report-chart-heading" : undefined}>
{children}
</h4>
);
},
pre: ({ children, ...props }) => {
if (fencedLanguage(children) !== "jyotish-chart") {
return <pre {...props}>{children}</pre>;
}
const block = parseReportChartBlock(fencedSource(children).trim());
if (!block) {
return <p className="personal-report-chart-invalid">图盘数据无效</p>;
}
return (
<figure className="personal-report-chart-figure">
<NorthIndianChartSvg
ariaLabel={chartAriaLabel(block.id)}
chart={toNorthIndianChart(block)}
/>
</figure>
);
},
};
}
function renderMarkdown(markdown: string, headings: readonly LongformHeading[]) {
return (
<ReactMarkdown
components={markdownComponents(headings)}
disallowedElements={["script", "iframe", "object", "embed", "img"]}
rehypePlugins={[reportChartGrid]}
remarkPlugins={[remarkGfm]}
skipHtml
unwrapDisallowed
urlTransform={reportUrlTransform}
>
{markdown}
</ReactMarkdown>
);
}
const LazyMarkdownSection = memo(function LazyMarkdownSection({
section,
}: {
section: LongformSection;
}) {
const ref = useRef<HTMLElement>(null);
const [visible, setVisible] = useState(section.eager);
useEffect(() => {
if (visible) return;
if (typeof IntersectionObserver === "undefined") {
const frame = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(frame);
}
if (!ref.current) return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) setVisible(true);
}, { rootMargin: "280px 0px" });
observer.observe(ref.current);
return () => observer.disconnect();
}, [visible]);
const content = useMemo(
() => (visible ? renderMarkdown(section.markdown, section.headings) : (
<h2 id={section.id}>{section.title}</h2>
)),
[visible, section],
);
return (
<section ref={ref} className="personal-report-md-section">
{content}
</section>
);
});
function ReportToc({ headings }: { headings: readonly LongformHeading[] }) {
const [activeId, setActiveId] = useState(headings[0]?.id ?? "");
useEffect(() => {
if (headings.length === 0 || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver((entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((left, right) => left.boundingClientRect.top - right.boundingClientRect.top);
const id = visible[0]?.target.id;
if (id) setActiveId(id);
}, { rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] });
for (const item of headings) {
const node = document.getElementById(item.id);
if (node) observer.observe(node);
}
return () => observer.disconnect();
}, [headings]);
return (
<nav aria-label="报告目录" className="personal-report-toc">
<details className="personal-report-toc-drawer">
<summary>目录</summary>
<TocList activeId={activeId} items={headings} />
</details>
<div className="personal-report-toc-desktop">
<p>目录</p>
<TocList activeId={activeId} items={headings} />
</div>
</nav>
);
}
export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
const lead = useMemo(
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings) : null),
[outline],
);
return (
<div className="personal-report-md-layout">
<ReportToc headings={outline.headings} />
<article className="personal-report-document personal-report-md-article">
{lead ? (
<section className="personal-report-md-lead">
{lead}
</section>
) : null}
{outline.sections.filter((section) => !section.eager).map((section) => (
<LazyMarkdownSection key={section.id} section={section} />
))}
</article>
</div>
);
}
function TocList({
items,
activeId,
}: {
items: readonly LongformHeading[];
activeId: string;
}) {
return (
<ol>
{items.map((item) => (
<li data-level={item.level} key={item.id}>
<a
aria-current={item.id === activeId ? "location" : undefined}
className={item.id === activeId ? "is-current" : undefined}
href={`#${item.id}`}
>
{item.title}
</a>
</li>
))}
</ol>
);
}