import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import { Box, Link, Typography } from '@mui/material' import type { Components } from 'react-markdown' import { MarkdownContent } from '../markdown/markdown-content' import { observeResize } from '../echart/shared-resize-observer' import { DEFAULT_NOTE_LABELS, type NoteLabels } from './labels' import { styles } from './style' export interface NoteProps { /** * Maximum visible lines before clamping. Defaults to 3. Set to `0` to * disable clamping entirely — full content renders and the Show More/Less * toggle is never shown. */ lineClamp?: number labels?: Partial /** * Renders verbatim inside the caption shell. For markdown source (a * string with `**bold**`, lists, links, etc.) reach for the * `` subcomponent instead — it pipes the string through * the shared {@link MarkdownContent} engine with caption-safe element * mapping. */ children: ReactNode } export interface NoteMarkdownProps { /** Markdown source. Rendered with caption typography via {@link MarkdownContent}. */ content: string /** Maximum visible lines before clamping. Defaults to 3. */ lineClamp?: number labels?: Partial } /** Block element used for every Markdown paragraph and demoted heading so * Notes share a single caption look and never inject heading levels into * the document outline. */ function CaptionBlock({ children }: { children?: ReactNode }) { return ( {children} ) } const NOTE_COMPONENTS: Components = { // Headings demoted to caption typography — a Note can't introduce a // heading into the page outline. h1: CaptionBlock, h2: CaptionBlock, h3: CaptionBlock, h4: CaptionBlock, h5: CaptionBlock, h6: CaptionBlock, p: CaptionBlock, // External-safe link defaults; inherit colour from the muted caption. a: ({ children, href }) => ( {children} ), } /** * Auxiliary slot for an optional explanatory note below a widget. Clamps to * `lineClamp` lines (default 3) and reveals a Show More/Less toggle when the * content overflows. Overflow detection re-runs on container resize via the * shared singleton ResizeObserver. * * Renders `children` verbatim through caption typography. For markdown * source, use the {@link NoteMarkdown} (`Note.Markdown`) subcomponent. */ function NoteBase({ lineClamp = 3, labels, children }: NoteProps) { return ( {children} ) } /** * Markdown variant of {@link Note}. Pipes the `content` string through the * shared {@link MarkdownContent} engine with caption-safe element mapping * (headings demoted, links open in new tabs, images/HTML stripped). */ function NoteMarkdown({ content, lineClamp = 3, labels }: NoteMarkdownProps) { return ( ) } // Public namespace export. Consumers reach the markdown variant via // `{content}`. export const Note = Object.assign(NoteBase, { Markdown: NoteMarkdown }) interface NoteShellProps { lineClamp: number labels: Partial | undefined /** Used to re-measure overflow when the underlying content changes. */ dependency: unknown children: ReactNode } /** * Shared shell: caption box + clamping + Show More/Less toggle. Both the * verbatim `Note` and the markdown `Note.Markdown` reach for this so the * overflow + measurement behaviour stays identical across variants. */ function NoteShell({ lineClamp, labels, dependency, children, }: NoteShellProps) { const _labels = { ...DEFAULT_NOTE_LABELS, ...labels } const textRef = useRef(null) const [isOverflowing, setIsOverflowing] = useState(false) const [expanded, setExpanded] = useState(false) const clampingDisabled = lineClamp <= 0 const measure = useCallback(() => { const node = textRef.current if (!node) return setIsOverflowing(node.scrollHeight > node.clientHeight + 1) }, []) useEffect(() => { if (clampingDisabled) return undefined const node = textRef.current if (!node) return undefined measure() return observeResize(node, measure) }, [measure, dependency, clampingDisabled]) const toggle = useCallback(() => setExpanded((v) => !v), []) const clamped = !expanded && !clampingDisabled const lineClampSx = clamped ? { ...styles.clamped, WebkitLineClamp: lineClamp } : null return ( {children} {/* Stay visible while the user is in the expanded state so they always have a way back. Without `|| expanded` the toggle would vanish the moment content unclamps (scrollHeight === clientHeight). Matches v1. */} {!clampingDisabled && (isOverflowing || expanded) && ( {expanded ? _labels.showLess : _labels.showMore} )} ) }