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 里能钉住的卡片,按页面先后,竖直坐标以筛选栏下沿为 0:画出来了、不在编辑的每一张(BlockCard 带 data-line;删掉的块那张没有), * 折叠着的、没有子块的也算,卡片自己就是一层。下沿算到它画出来的子块的最后(它名下的子层带 data-owner,挂在子行上的子块那层在卡片里面)。 * 只留可能钉住的:上沿在钉住的那几行(高 band)以内或更高、下沿还在筛选栏下沿以下 */ function measureScopes(root: HTMLElement, band: number): StickyScope[] { const bottoms = new Map() for (const level of root.querySelectorAll('.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('.cr-block[data-line]:not([data-editing])')) { const id = card.dataset.id ?? '' const rect = card.getBoundingClientRect() const top = rect.top - base const bottom = Math.max(rect.bottom, bottoms.get(id) ?? rect.bottom) - base if (top < band && bottom >= 0) scopes.push({ id, depth: depthOf(card), top, bottom, left: rect.left, right: rect.right }) } return scopes } /** VS Code 的 Sticky Scroll 设置:扩展端 ready 后先于第一份文档发来,设置改了再发;收到之前不钉。 */ export function useStickySettings(): StickyScrollSettings | undefined { const [settings, setSettings] = useState() 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, 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 limit = limitOf(maxLineCount) const next = root ? pinnedLines(measureScopes(root, limit * STICKY_LINE_HEIGHT), STICKY_LINE_HEIGHT, limit) : 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('.cr-sticky')?.offsetHeight ?? 0 }