左侧时间线轨道:每条消息一个节点,节点下挂 H2 二级索引与高亮标记;点击可跳转与查看高亮。
我们现在有一个 chatbot, 对话记录可能在 2 到 50 条之间。每条消息是一个独立的 markdown 片段, 包含正文与标题 (H1/H2/H3)。
我不想让目录占据太多空间, 也不想它像一个 sidebar。更理想的体验是: 内容优先、导航轻量、交互自然, 类似 Apple 的细节、Vercel/Next 的克制。
为了压力测试滚动联动, 这里故意写得更长一些: 我希望你能在连续滚动很多屏的情况下, 仍然看到 active 节点跟随变化, 并且点击节点跳转后不会丢失上下文。也就是说, 这个“导航”要像一把细尺, 而不是一个抽屉。
目标是把每条消息以及消息中的 H1/H2/H3 抽取出来, 但导航只展示 message 的主标题 (H1) 与二级标题 (H2)。H3 不在 timeline 展示, 但它会影响 H2 的内容分段与高亮归属。
- message: { id, role, content } - content: markdown 片段 - headings: 从 content 提取 H1/H2/H3 - highlights: 用户在正文里划选并高亮的片段 (仅高亮, 不做 memo)
补充一段更长的描述: highlights 需要能映射到 message, 也需要能映射到某个 section(H2) 的范围内, 这样 timeline 才能在 message 节点与 H2 行上都显示计数。你在测试时可以在同一段落里打多个 highlight, 看 pieces 渲染是否稳定。
1. 点击圆点: 跳转到对应 message 2. hover/active: 展开该 message 的 H2 列表 3. 点击 H2: 跳转到对应标题位置 4. 点击高亮数量: 弹出 popover, 展示该 message 或该 H2 区域内的高亮列表
需要用长内容测试滚动联动: 多屏滚动时 active node 仍准确, 跳转落点稳定, popover 不遮挡关键内容。并且 “展开/收起” 不要出现你之前说的那种先压缩再撑高的糟糕体验。
对话天然是时间序列, timeline 更符合心智模型。用户滚动阅读时, 希望导航像一个进度尺一样同步移动, 而不是频繁开合面板。
我们把每条消息当作一篇 mini 文档来处理。解析时只做最小假设: 按行扫描 markdown, 识别 #/##/### 三个层级, 其余合并为段落。
- # -> H1 (message 的主标题) - ## -> H2 (timeline 展示的二级标题) - ### -> H3 (不在 timeline 展示, 但会影响 H2 的作用域)
H2 的作用域从它自身开始, 直到遇到下一个 level <= 2 的标题 (即下一个 H2 或 H1) 为止。这样我们能把高亮映射到属于哪个 H2 区块。
为了更真实地模拟文档, 每个 H2 下也写一些段落。段落越长, 对 offset 计算、滚动定位与 popover 定位的压力就越大。你可以选中某一段中的任意子串去高亮, 再从 popover 跳回来。
1) split lines 2) 识别 heading 行, flush 段落 3) 生成 blocks (h1/h2/h3/p) 4) 从 blocks 生成 nav (message title + h2 list)
1. 没有 H1: 用正文前 28 个字符作为 fallback title 2. H2 很多: timeline 默认折叠, 仅 hover/active 展开 3. 标题很长: timeline 里截断, 保留 title 属性用于悬浮查看
- nav: [{ messageId, title, h2: [{ title, blockIndex, scopeEndBlockIndex }] }] - blocksByMessage: { [messageId]: blocks[] }
解析是 O(n) 线性扫描, n 为字符/行数。避免引入 markdown AST, 让 demo 轻量可读, 也更容易被你拿去集成到真实工程里。
整体风格偏 Next/Vercel: 灰阶、轻边框、弱阴影、少色彩; 交互细节偏 Apple: 轻微动效反馈、跳转后的状态提示、克制但精致。
主色保持中性, 黄色只用于 highlight 信息提示。节点与线条尽量轻, 让内容成为焦点。你会发现只要信息层级清晰, timeline 不需要“很大”也能有足够的存在感。
左侧是 1 列窄布局: - 竖向连接线把每个节点串起来 - 圆点是主交互入口 (跳转 message) - 高亮 badge 是信息提示 + popover 入口
圆点与 H1 title 必须严格对齐, 避免视觉抖动。H2 列表在 hover/active 时展开, 使用高度动画但不压缩其他布局。要保证展开发生时不会“推挤”到别的节点从而看起来像压缩再撑高。
- 展开: height 从 0 -> auto, opacity 渐入 - 跳转高亮: mark pulse (opacity/ring) - popover: 从 badge 位置弹出, fixed 定位
hover 只是辅助, 核心动作都可以 click 完成。未来建议加 aria-label 与键盘导航 (上下切节点, Enter 跳转)。
lg 以上显示 timeline, 小屏可考虑隐藏或换成顶部轻量跳转控件。当前 demo 只实现 lg 显示。
我会在内容区域划选文本, 然后点击 Highlight 创建高亮。这里不做 memo, 只做纯高亮即可。
仅支持在同一个 block (同一段落或同一标题行) 内 selection。跨 block selection 直接忽略。这样能避免复杂 DOM range 映射, 让 demo 更稳。
用 TreeWalker 线性遍历文本节点, 计算 start/end 偏移。存储时只记录偏移, 不记录 DOM。
为了让测试更接近真实, 这里写更长的段落: selection 的 start/end 偏移必须能在重新渲染后仍然指向同一段文本, 否则高亮会漂移。对于特别长的段落, 也要避免 offset 计算出现性能问题。你可以反复选中不同位置创建多个高亮, 看渲染是否仍稳定。
用 <mark> 包裹高亮段, 按 start/end 切分文本为 pieces。多个高亮需要按 start 排序并处理重叠。
popover 里展示 excerpt (截断到 70 字), 按时间倒序。点击某项: 关闭 popover, 跳转到 mark 并强调。
跳转到 mark 后, 需要一个短暂的强调效果, 让用户能立即定位到落点。我们用 ring + opacity pulse 来实现。
长段落时 selection offset 仍要稳定。滚动定位要考虑 sticky header, 可用 scroll-mt-* 做基础处理。
我们用 IntersectionObserver 做 scroll-spy: 当某条 message 的卡片进入可视区域时, 把 timeline 的 active node 切换到它。
- threshold 设为 0.2 - rootMargin 上负下负, 让 active 更贴合阅读视窗
阈值不是越小越好, 太敏感会抖动。我们用 0.2 配合 rootMargin, 在长文档里通常更稳定。你可以快速滚动看看 active 变化是否自然。
如果内容很长, active 可能来回切换。可以引入 hysteresis 或把 active 设为最靠近顶部的 intersecting 元素。这里 demo 用“最靠近顶部”排序法。
message/H2/mark 都用 scrollIntoView smooth。H2 容器加 scroll-mt-24 预留顶部空间, 避免标题被顶到屏幕上沿看不见。
popover 用 fixed, anchor 取点击 badge 的 rect。右侧优先, 不足则贴边。y 方向限制在视窗内。
只观察 message 容器, 不观察每个 block。高亮渲染只在对应 block 内切分, 避免全局重排。
虽然当前 demo 只做高亮, 但结构上应当允许未来扩展: memo、标签、导出、持久化等。
- memo: 高亮上挂备注 (可选) - 标签: Bug/Idea/Todo - 搜索: timeline 快速过滤 message/h2
未来可以把 highlights 存到 localStorage 或后端。key 可组合 conversationId + messageId。这里 demo 不做持久化, 但数据结构已经适配。
- TimelineRail - ContentColumn - SelectionToolbar - FloatingPopover
当前用 useState + useMemo。产品化可抽 hook (useHighlights/useScrollSpy/usePopover)。你也可以把 “parseBlocks/buildNav” 抽到 utils 里。
纯函数部分 (parseBlocks/buildNav) 可以用 console.assert 或单元测试框架验证。交互部分可用 e2e 或 storybook。
节点密度高时 hover 展开会很挤, 可以限制展开数量或只在 active 时完整展开。
我会用这份 demo 做压力测试, 所以需要每条消息内容足够长且 H2 较多。
快速滚动: active node 不应乱跳。慢速滚动: active node 应紧跟阅读位置。中速滚动: 不要频繁在两个节点之间抖动。
点击 message 圆点、H2 标题、popover 高亮项, 都应稳定跳转并且落点准确。跳转后要保持阅读上下文, 不要出现“跳过去但看不到重点”的情况。
短 selection / 长 selection。标题行高亮 / 长段落高亮。多个高亮同段落渲染稳定, 不要错位、不重叠、不丢字符。
popover 在视窗底部/右侧边缘时不能溢出。点击外部关闭, Esc 关闭。重复点同一个 badge 应切换开关。
增加到 30~50 条 message, 每条 4~6 个 H2, 仍应流畅。你可以复制 demoMessages 增加数量做更极端测试。
hover 展开与 active 展开要一致, 不要出现突然闪烁或布局抖动。
如果 demo 的滚动联动与交互稳定, 下一步可以考虑把它变成可复用组件。
active 连接线加深形成 progress rail。hover 态更细腻, 但保持克制。可以对 active 的 dot 做更轻的视觉强调。
支持 memo, 支持标签, 支持搜索过滤, 支持导出高亮摘要。memo 的展示可以在 popover 里做二级展开。
把解析与导航构建抽到独立模块, 并加单元测试。把滚动与 popover 逻辑抽为 hooks。把 anchor 计算、边界限制封装成 util。
把高亮聚合成摘要, 或导出为 markdown/JSON。甚至可以把每个 H2 的 highlights 做一个自动摘要输出。
让跳转后的落点更清晰, 让 popover 的打开关闭更稳定, 并考虑将来加键盘导航与可访问性属性。
DRY、组件化、交互自然、样式克制。确保在不同内容长度下都稳定。