fix(report): group chart cards in the existing grid and stop remounting on scroll (BUG-616, BUG-617)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3832,31 +3832,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-size: var(--type-title-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
/* Charts live in the eager 摘要 lead, not only later lazy H2 sections. */
|
||||
.personal-report-md-lead > h4.personal-report-chart-heading:has(+ .personal-report-chart-figure),
|
||||
.personal-report-md-section > h4.personal-report-chart-heading:has(+ .personal-report-chart-figure) {
|
||||
float: left;
|
||||
width: 50%;
|
||||
box-sizing: border-box;
|
||||
padding-inline: var(--space-3);
|
||||
margin-top: var(--space-5);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.personal-report-md-lead > h4.personal-report-chart-heading + .personal-report-chart-figure,
|
||||
.personal-report-md-section > h4.personal-report-chart-heading + .personal-report-chart-figure {
|
||||
float: left;
|
||||
width: 50%;
|
||||
margin-left: -50%;
|
||||
margin-top: var(--space-5);
|
||||
padding-top: 2.4em;
|
||||
box-sizing: border-box;
|
||||
max-width: none;
|
||||
}
|
||||
.personal-report-md-lead > .personal-report-chart-figure + :not(h4):not(.personal-report-chart-figure),
|
||||
.personal-report-md-section > .personal-report-chart-figure + :not(h4):not(.personal-report-chart-figure) {
|
||||
clear: both;
|
||||
}
|
||||
.personal-report-md-article .personal-report-chart-grid {
|
||||
margin: var(--space-5) 0 var(--space-6);
|
||||
}
|
||||
.personal-report-md-article .personal-report-chart-card h4 {
|
||||
margin: 0;
|
||||
font-size: var(--type-title-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.personal-report-chart-card .personal-report-chart-figure {
|
||||
max-width: none;
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.personal-report-md-layout { grid-template-columns: 1fr; }
|
||||
@@ -4418,23 +4404,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.personal-report-narrative p { font-size: 9.6pt; line-height: 1.72; orphans: 3; widows: 3; }
|
||||
.personal-report-lead-grid { grid-template-columns: minmax(0, 1.04fr) minmax(64mm, .96fr); gap: 8mm; }
|
||||
.personal-report-chart-svg { max-width: none; margin-top: 3mm; }
|
||||
.personal-report-md-article .personal-report-chart-figure {
|
||||
float: none;
|
||||
.personal-report-md-article .personal-report-chart-card .personal-report-chart-figure {
|
||||
max-width: none;
|
||||
width: auto;
|
||||
max-width: 360px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin: 3mm 0 0;
|
||||
padding-top: 0;
|
||||
break-inside: avoid;
|
||||
}
|
||||
.personal-report-md-article h4.personal-report-chart-heading {
|
||||
float: none;
|
||||
width: auto;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 360px;
|
||||
.personal-report-md-article .personal-report-chart-card h4 {
|
||||
max-width: none;
|
||||
padding-inline: 0;
|
||||
}
|
||||
.personal-report-chart-card { break-inside: avoid; }
|
||||
.personal-report-chart-grid,
|
||||
.personal-report-chart-grid.is-single { grid-template-columns: 1fr; }
|
||||
.personal-report-chart-grid.is-single { max-width: 118mm; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
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,
|
||||
@@ -90,6 +91,7 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[])
|
||||
<ReactMarkdown
|
||||
components={markdownComponents(headings)}
|
||||
disallowedElements={["script", "iframe", "object", "embed", "img"]}
|
||||
rehypePlugins={[reportChartGrid]}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
skipHtml
|
||||
unwrapDisallowed
|
||||
@@ -100,7 +102,7 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[])
|
||||
);
|
||||
}
|
||||
|
||||
function LazyMarkdownSection({
|
||||
const LazyMarkdownSection = memo(function LazyMarkdownSection({
|
||||
section,
|
||||
}: {
|
||||
section: LongformSection;
|
||||
@@ -120,21 +122,24 @@ function LazyMarkdownSection({
|
||||
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">
|
||||
{visible ? renderMarkdown(section.markdown, section.headings) : (
|
||||
<h2 id={section.id}>{section.title}</h2>
|
||||
)}
|
||||
{content}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
|
||||
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
|
||||
const [activeId, setActiveId] = useState(outline.headings[0]?.id ?? "");
|
||||
function ReportToc({ headings }: { headings: readonly LongformHeading[] }) {
|
||||
const [activeId, setActiveId] = useState(headings[0]?.id ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (outline.headings.length === 0 || typeof IntersectionObserver === "undefined") return;
|
||||
if (headings.length === 0 || typeof IntersectionObserver === "undefined") return;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
@@ -142,29 +147,41 @@ export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
|
||||
const id = visible[0]?.target.id;
|
||||
if (id) setActiveId(id);
|
||||
}, { rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] });
|
||||
for (const item of outline.headings) {
|
||||
for (const item of headings) {
|
||||
const node = document.getElementById(item.id);
|
||||
if (node) observer.observe(node);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [outline.headings]);
|
||||
}, [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">
|
||||
<nav aria-label="报告目录" className="personal-report-toc">
|
||||
<details className="personal-report-toc-drawer">
|
||||
<summary>目录</summary>
|
||||
<TocList activeId={activeId} items={outline.headings} />
|
||||
</details>
|
||||
<div className="personal-report-toc-desktop">
|
||||
<p>目录</p>
|
||||
<TocList activeId={activeId} items={outline.headings} />
|
||||
</div>
|
||||
</nav>
|
||||
<ReportToc headings={outline.headings} />
|
||||
<article className="personal-report-document personal-report-md-article">
|
||||
{outline.leadMarkdown ? (
|
||||
{lead ? (
|
||||
<section className="personal-report-md-lead">
|
||||
{renderMarkdown(outline.leadMarkdown, outline.headings)}
|
||||
{lead}
|
||||
</section>
|
||||
) : null}
|
||||
{outline.sections.filter((section) => !section.eager).map((section) => (
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
const CHART_HEADING_RE = /^(?:D\d{1,3}\b|Moon Chart)/;
|
||||
|
||||
type HastProperties = {
|
||||
className?: string | string[] | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type HastNode = {
|
||||
type: string;
|
||||
tagName?: string;
|
||||
value?: string;
|
||||
properties?: HastProperties;
|
||||
children?: HastNode[];
|
||||
};
|
||||
|
||||
type HastRoot = {
|
||||
type: "root";
|
||||
children: HastNode[];
|
||||
};
|
||||
|
||||
function classNames(node: HastNode | undefined): string[] {
|
||||
const value = node?.properties?.className;
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
if (typeof value === "string") return value.split(/\s+/).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
function hasClass(node: HastNode | undefined, name: string): boolean {
|
||||
return classNames(node).includes(name);
|
||||
}
|
||||
|
||||
function isWhitespace(node: HastNode | undefined): boolean {
|
||||
return Boolean(node && node.type === "text" && !String(node.value ?? "").trim());
|
||||
}
|
||||
|
||||
function flattenText(node: HastNode | undefined): string {
|
||||
if (!node) return "";
|
||||
if (node.type === "text") return String(node.value ?? "");
|
||||
if (!Array.isArray(node.children)) return "";
|
||||
return node.children.map(flattenText).join("");
|
||||
}
|
||||
|
||||
function isElement(node: HastNode | undefined, tagName: string): node is HastNode {
|
||||
return Boolean(node && node.type === "element" && node.tagName === tagName);
|
||||
}
|
||||
|
||||
function isChartHeading(node: HastNode | undefined): boolean {
|
||||
return isElement(node, "h4") && CHART_HEADING_RE.test(flattenText(node).trim());
|
||||
}
|
||||
|
||||
function isChartPre(node: HastNode | undefined): boolean {
|
||||
if (!isElement(node, "pre") || !Array.isArray(node.children)) return false;
|
||||
const code = node.children.find((child) => isElement(child, "code"));
|
||||
return hasClass(code, "language-jyotish-chart") || hasClass(code, "jyotish-chart");
|
||||
}
|
||||
|
||||
function skipWhitespace(nodes: readonly HastNode[], index: number): number {
|
||||
let cursor = index;
|
||||
while (cursor < nodes.length && isWhitespace(nodes[cursor])) cursor += 1;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function element(tagName: string, className: string[], children: HastNode[]): HastNode {
|
||||
return {
|
||||
type: "element",
|
||||
tagName,
|
||||
properties: { className },
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCard(nodes: readonly HastNode[], index: number): { card: HastNode; next: number } | null {
|
||||
const heading = nodes[index];
|
||||
if (!isChartHeading(heading)) return null;
|
||||
const children: HastNode[] = [heading];
|
||||
let cursor = skipWhitespace(nodes, index + 1);
|
||||
if (isElement(nodes[cursor], "p")) {
|
||||
const afterParagraph = skipWhitespace(nodes, cursor + 1);
|
||||
if (isChartPre(nodes[afterParagraph])) {
|
||||
children.push(nodes[cursor] as HastNode);
|
||||
cursor = afterParagraph;
|
||||
}
|
||||
}
|
||||
if (!isChartPre(nodes[cursor])) return null;
|
||||
children.push(nodes[cursor] as HastNode);
|
||||
return {
|
||||
card: element("div", ["personal-report-chart-card"], children),
|
||||
next: cursor + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function groupReportChartNodes(nodes: readonly HastNode[]): HastNode[] {
|
||||
const grouped: HastNode[] = [];
|
||||
let index = 0;
|
||||
while (index < nodes.length) {
|
||||
const first = parseCard(nodes, index);
|
||||
if (!first) {
|
||||
grouped.push(nodes[index] as HastNode);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const cards = [first.card];
|
||||
index = first.next;
|
||||
while (index < nodes.length) {
|
||||
const peeked = skipWhitespace(nodes, index);
|
||||
const next = parseCard(nodes, peeked);
|
||||
if (!next) break;
|
||||
cards.push(next.card);
|
||||
index = next.next;
|
||||
}
|
||||
grouped.push(element(
|
||||
"div",
|
||||
cards.length === 1
|
||||
? ["personal-report-chart-grid", "is-single"]
|
||||
: ["personal-report-chart-grid"],
|
||||
cards,
|
||||
));
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function reportChartGrid() {
|
||||
return function groupReportCharts(tree: HastRoot) {
|
||||
if (tree?.type !== "root" || !Array.isArray(tree.children)) return;
|
||||
tree.children = groupReportChartNodes(tree.children);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user