✨ feat(vscode): 代码阅读滚进卡片的子块时把外层卡片钉在顶上,同 VS Code 的 Sticky Scroll

This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent 5ce2e904f4
commit eb3d1b90cd
17 files changed
+548 -31

No files matched your search

@@ -0,0 +1,105 @@
import type { RefObject } from 'react'
import type { PinnedLines, StickyScope } from './stickyScroll'
import type { StickyScrollSettings } from './types'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { insetAbove, lineLimit, NO_PINNED, pinnedLines, samePinned } from './stickyScroll'
import { pinnedHeight } from './useScrollPosition'
import { onMessage } from './vscode'
/** 钉在顶上的一行多高(像素) */
export const STICKY_LINE_HEIGHT = 24
/** 卡片外面套着几张卡片:它所在的各层子层(BlockLevel 画的、带 data-owner 的)一层算一张 */
function depthOf(card: Element): number {
let depth = 0
for (let level = card.closest('.cr-level[data-owner]'); level; level = level.parentElement?.closest('.cr-level[data-owner]') ?? null)
depth++
return depth
}
/** 筛选栏下面的可视区域里最多钉几行 */
function limitOf(maxLineCount: number): number {
return lineLimit(window.innerHeight - pinnedHeight(), STICKY_LINE_HEIGHT, maxLineCount)
}
/** root 里能钉住的卡片:画出了子层的那些(子层带 data-owner,挂在子行上的子块那层在卡片里面),按页面先后;竖直坐标以筛选栏下沿为 0 */
function measureScopes(root: HTMLElement): StickyScope[] {
const bottoms = new Map<string, number>()
for (const level of root.querySelectorAll<HTMLElement>('.cr-level[data-owner]')) {
const owner = level.dataset.owner ?? ''
bottoms.set(owner, Math.max(bottoms.get(owner) ?? Number.NEGATIVE_INFINITY, level.getBoundingClientRect().bottom))
}
const base = pinnedHeight()
const scopes: StickyScope[] = []
for (const card of root.querySelectorAll<HTMLElement>('.cr-block[data-id]')) {
const id = card.dataset.id ?? ''
const levelBottom = bottoms.get(id)
if (levelBottom === undefined)
continue
const rect = card.getBoundingClientRect()
scopes.push({ id, depth: depthOf(card), top: rect.top - base, bottom: Math.max(rect.bottom, levelBottom) - base, left: rect.left, right: rect.right })
}
return scopes
}
/** VS Code 的 Sticky Scroll 设置:扩展端 ready 后先于第一份文档发来,设置改了再发;收到之前不钉。 */
export function useStickySettings(): StickyScrollSettings | undefined {
const [settings, setSettings] = useState<StickyScrollSettings | undefined>()
useEffect(() => onMessage((msg) => {
if (msg.type === 'stickyScroll')
setSettings({ enabled: msg.enabled, maxLineCount: msg.maxLineCount })
}), [])
return settings
}
/**
* 眼下 content 里钉着哪几张卡片(见 pinnedLines),最多 maxLineCount 行(窗口矮时更少,见 lineLimit)。滚动、窗口大小变了、内容的大小变了、用它的组件重画了(页面状态变了)时排一次,
* 每帧最多量一次、算一次(requestAnimationFrame);钉的没变不更新。只有用它的组件跟着重画,块树不动。
*/
export function useStickyLines(content: RefObject<HTMLElement | null>, maxLineCount: number): PinnedLines {
const [lines, setLines] = useState(NO_PINNED)
const schedule = useRef<() => void>(() => {})
useEffect(() => {
let frame = 0
const update = (): void => {
frame = 0
const root = content.current
const next = root ? pinnedLines(measureScopes(root), STICKY_LINE_HEIGHT, limitOf(maxLineCount)) : NO_PINNED
setLines(prev => samePinned(prev, next) ? prev : next)
}
schedule.current = () => {
if (frame === 0)
frame = requestAnimationFrame(update)
}
const onChange = (): void => schedule.current()
const observer = new ResizeObserver(onChange)
if (content.current)
observer.observe(content.current)
window.addEventListener('scroll', onChange, { passive: true })
window.addEventListener('resize', onChange)
onChange()
return () => {
cancelAnimationFrame(frame)
observer.disconnect()
window.removeEventListener('scroll', onChange)
window.removeEventListener('resize', onChange)
schedule.current = () => {}
}
}, [content, maxLineCount])
// 展开、折叠、筛选、换文档都会让用它的组件跟着页面重画:画完再量(大小没变时 ResizeObserver 不报)
useLayoutEffect(() => schedule.current())
return lines
}
/** 一张卡片滚到顶上时要让出的高度:它上面会钉着的外层卡片(Sticky Scroll 关着时没有)。 */
export function stickyInset(card: Element, settings: StickyScrollSettings | undefined): number {
return settings?.enabled ? insetAbove(depthOf(card), STICKY_LINE_HEIGHT, limitOf(settings.maxLineCount)) : 0
}
/** 眼下钉着的几行占多高(StickyLines 画的 .cr-sticky),没钉时是 0。 */
export function stickyHeight(): number {
return document.querySelector<HTMLElement>('.cr-sticky')?.offsetHeight ?? 0
}