/** * [INPUT]: 依赖 react(useState)、lib/markdown 的 parseBlocks/parseInline/CALLOUT_RGB(语义单一真相源)、 * lucide-react(标注块 13 图标 + 折叠箭头)、course/ImageLightbox(图片放大); * props: md(Markdown 文本)、onJump(lessonId) 视频跳转回调 * [OUTPUT]: 对外提供 MarkdownNotes 组件(课程笔记的 **v4 纸墨朱皮肤**)+ defaultNotes(无笔记时的模板) * [POS]: components/v4 共享原语,视频页笔记区(screens/v4/video/NotesPane)消费。 * **本文件只负责「长什么样」**——「什么是表格/标注块/链接」的语义全在 lib/markdown, * 与法律条款弹窗(course/MarkdownContent)共用同一份解析,语义修一处两处同得。 * 标注块配色=Obsidian 官方色板(老大定:笔记区是【内容】不是产品 UI, * Obsidian 老用户零学习成本;产品 UI 仍严守纸墨朱)。 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ import { useMemo, useState } from 'react' import { AlertTriangle, Bug, Check, CheckCircle2, ChevronRight, ClipboardList, Flame, HelpCircle, Info, List, Pencil, Quote, X, Zap, } from 'lucide-react' import { parseBlocks, parseInline, CALLOUT_RGB } from '../../lib/markdown' import { ImageLightbox } from '../course/ImageLightbox' const MONO = 'var(--zzm-mono)' const ACC = 'var(--zzm-accent,#A6402F)' const INK = 'var(--zzm-ink,#161616)' const TEXT = 'var(--zzm-text-2,#3F3F3B)' const HAIR = 'var(--zzm-hairline-2,rgba(0,0,0,.09))' // 标注块图标注册表:lib/markdown 的 icon 名(字符串)→ lucide 组件。 // 语义层不认识 React 故只存名字,映射落在渲染层——同 lib/siteIcons 既有范式。 const ICONS = { Pencil, ClipboardList, Info, CheckCircle2, Flame, Check, HelpCircle, AlertTriangle, X, Zap, Bug, List, Quote, } // ── 行内 token → JSX(4pt 基线:正文 13.5px → 行高 24px)── function Inline({ text, onJump }) { return parseInline(text).map((t, i) => { switch (t.type) { case 'strong': return {t.value} case 'code': return ( {t.value} ) case 'jump': // 课节跳转胶囊(v4 产品核心):站内导航,非外链 return ( { ev.preventDefault(); onJump?.(t.target) }} style={{ display: 'inline-flex', alignItems: 'center', padding: '1px 9px', margin: '0 2px', borderRadius: 999, background: 'rgba(166,64,47,.07)', color: ACC, fontSize: '.9em', fontWeight: 600, textDecoration: 'none', cursor: 'pointer', border: '1px solid rgba(166,64,47,.28)', }} >{t.text} ) case 'link': // href 为 null = 未过 safeHref 白名单(javascript: 等)→ 降级纯文本,绝不产出可点元素 if (!t.href) return {t.text} return ( {t.text} ) case 'image': return t.src ? {t.alt} : {t.alt} default: return {t.value} } }) } // ── 标注块(Obsidian callout):左缘色条 + 淡色底 + 图标标题行 + 可折叠 ── function Callout({ block, onJump, onZoom }) { const [open, setOpen] = useState(block.fold !== '-') // '-' 默认折叠 / '+' 默认展开 / null 不可折叠 const rgb = CALLOUT_RGB[block.color] ?? CALLOUT_RGB.blue const Icon = ICONS[block.icon] ?? Info const foldable = block.fold !== null const hasBody = block.children.length > 0 // ⚠️ 结构性保证:不可折叠的标注块【恒展开】,绝不看 open 态。 // 切课节时 MarkdownNotes 不卸载(NotesPane 无 key),若上一节同下标处是个被折起的标注块, // 其 open=false 会残留到本节;本节若不可折叠就没有展开入口 → 正文永久看不见。 // 一条 `[!CAUTION] 数据丢失重灾区` 被永久藏起来是灾难级的,故这里不靠 key 侥幸,直接消除该可能。 const show = !foldable || open return (
setOpen((v) => !v) : undefined} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: hasBody && show ? '12px 16px 8px' : '12px 16px', cursor: foldable ? 'pointer' : 'default', userSelect: foldable ? 'none' : 'auto', }} > {foldable && ( )}
{hasBody && show && (
)}
) } // ── 表格:笔记栏可窄至 260px,故必须自己横向滚动,绝不把版面撑破 ── function Table({ block, onJump }) { const cell = { border: `1px solid ${HAIR}`, padding: '8px 12px', fontSize: 12.5, lineHeight: '20px', color: TEXT, verticalAlign: 'top', } return (
{block.head.map((c, i) => ( ))} {block.rows.length > 0 && ( {block.rows.map((row, ri) => ( {row.map((c, ci) => ( ))} ))} )}
) } const LI = { fontSize: 13.5, lineHeight: '24px', color: TEXT } const P = { margin: '8px 0', fontSize: 13.5, lineHeight: '24px', color: TEXT } // 字阶逐字沿用旧 MarkdownNotes:# 17 / ## 15 / ### 13.5(h3/h4/h5)——旧版只认三级, // 四级以下沿用三级的 13.5。⚠️ 不可把 ## 顶到 17:那样 # 与 ## 同大小、标题层级当场消失, // 且 defaultNotes 模板里的 `## 本节要点` 会平白变大。 const H = { 1: 17, 2: 15, 3: 13.5, 4: 13.5, 5: 13.5, 6: 13.5 } function Blocks({ blocks, onJump, onZoom, tight }) { return blocks.map((b, i) => { switch (b.type) { case 'heading': { const Tag = `h${Math.min(6, b.level + 2)}` // 笔记嵌在页面里,标题层级整体下沉两级(旧版 #→h3) return ( ) } case 'p': return

case 'ul': case 'ol': { // 嵌套列表:item.children 是子块(子列表/标注块…)——Obsidian 笔记的嵌套项目符号靠这里成形 const Tag = b.type return ( {b.items.map((it, j) => (
  • {it.children.length > 0 && ( )}
  • ))}
    ) } case 'code': // 缩进逐字保真(whiteSpace:pre + 不 trim)→ 复制出去能直接跑,这是教编程的课的底线 return (
                {b.content}
              
    ) case 'table': return case 'callout': // key 含内容指纹(非纯下标):换课节时同下标处若是【另一个】标注块,必须是新实例, // 否则上一节的折叠态串到这一节。(配合 Callout 内 `show` 的结构性保证,双保险) return case 'quote': return (
    ) case 'image': return b.src ? ( {b.alt} onZoom({ src: b.src, alt: b.alt })} style={{ margin: '12px 0', maxWidth: '100%', borderRadius: 8, cursor: 'zoom-in', display: 'block', }} /> ) : null case 'hr': return
    default: return null } }) } export function MarkdownNotes({ md, onJump }) { const [zoom, setZoom] = useState(null) // { src, alt } | null // 只随 md 变化重解析:拖宽笔记栏时每个 mousemove 都会经 V4Nav 的 notesW 触发本组件重渲染, // 无 memo 则整篇笔记每帧重解析一遍——解析本身快,但没理由每帧白烧。 const blocks = useMemo(() => parseBlocks(md), [md]) return (
    setZoom(null)} />
    ) } /** 课节无笔记时的默认笔记(逐字移植 DC defNotes) */ export function defaultNotes(title, desc) { return `## 本节要点\n- ${desc}\n- 先完整看一遍演示,再动手跟做。\n- 示例库与配套素材见本节附件。\n\n## 跟练清单\n1. 按视频步骤,在自己的环境中完成一遍「${title}」。\n2. 把疑问记入「问题收件箱」,下一节统一解答。\n3. 完成后标记本节,集齐后地图星点将点亮。\n\n> 小提示:用 **双链** 把本节笔记连到你的 \`MOC\` 索引页。` }