218 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|