105 lines
4.8 KiB
TypeScript
105 lines
4.8 KiB
TypeScript
import type { ReactElement, RefObject } from 'react'
|
|
import type { BlockView, StickyScrollSettings } from './types'
|
|
import { useContext, useLayoutEffect, useMemo } from 'react'
|
|
import { KindIcon } from './kindIcons'
|
|
import { SegmentText, TextMarksContext } from './SegmentText'
|
|
import { selectStore } from './store'
|
|
import { flash } from './useReveal'
|
|
import { pinnedHeight, usePinnedHeight } from './useScrollPosition'
|
|
import { STICKY_LINE_HEIGHT, stickyInset, useStickyLines } from './useStickyScroll'
|
|
|
|
/** 块 id → 块,连同各层子块 */
|
|
function blocksById(blocks: readonly BlockView[]): Map<string, BlockView> {
|
|
const byId = new Map<string, BlockView>()
|
|
const visit = (list: readonly BlockView[]): void => {
|
|
for (const block of list) {
|
|
byId.set(block.id, block)
|
|
visit(block.children)
|
|
}
|
|
}
|
|
visit(blocks)
|
|
return byId
|
|
}
|
|
|
|
/**
|
|
* 钉住的一行里卡片的头:折叠箭头那格留空、概念图标、标题,各自的位置同卡片上的,一行放不下省略。
|
|
* 标题同卡片上的写法(中文名照换):属性表的第一行(名称)的值,或一行译文;Markdown 只取第一行。
|
|
*/
|
|
export function PinnedHead({ block }: { block: BlockView }): ReactElement {
|
|
const title = 'properties' in block ? block.properties[0]?.value : block.translation
|
|
return (
|
|
<div className="flex h-full min-w-0 items-center gap-2.5 pr-2.5 pl-[9px]">
|
|
<span className="w-3.5 shrink-0" />
|
|
<KindIcon concepts={block.concepts} />
|
|
<p className="cr-code cr-sticky-title min-w-0 flex-1">
|
|
{title && ('markdown' in title
|
|
? <span className="cr-role-comment">{title.markdown.split('\n', 1)[0].trim()}</span>
|
|
: <SegmentText segments={title} />)}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 钉在顶上的卡片(同 VS Code 编辑器的 Sticky Scroll,设置也用它的,一张卡片就是一层):卡片的开头已经滚到筛选栏后面、卡片(连同子块)
|
|
* 还有一部分看得见时,筛选栏下面钉一行这张卡片的头(PinnedHead),外层的在上、里层的往下摞,每行和那张卡片左右对齐(越里层越往右);
|
|
* 这张卡片快滚完时,最里那行被下面的内容顶上去、从上一行下面滑走。哪几张见 pinnedLines,只按页面上画出来的算:
|
|
* 折叠着的卡片自己照样钉(它的子块没画出来,不算);被筛掉的不在页面上,正在编辑的不钉。点一行把那张卡片滚到顶上(外层还钉着的几行下面)、闪几下。
|
|
* 钉着的名字不画页面上的高亮(页面上找当前那一处只认卡片里的);跳到定义、悬停看卡片照旧。
|
|
* content 是块树所在的页面内容,blocks 是这份文档的块。
|
|
*/
|
|
export function StickyLines({ content, blocks, settings }: { content: RefObject<HTMLElement | null>, blocks: readonly BlockView[], settings: StickyScrollSettings }): ReactElement | null {
|
|
const top = usePinnedHeight()
|
|
const lines = useStickyLines(content, settings.maxLineCount)
|
|
const byId = useMemo(() => blocksById(blocks), [blocks])
|
|
// 钉着的名字照换中文名,不画页面上的高亮
|
|
const marks = useContext(TextMarksContext)
|
|
const unhighlighted = useMemo(() => selectStore(marks, ({ names }) => ({ names })), [marks])
|
|
const count = lines.scopes.length
|
|
const height = count * STICKY_LINE_HEIGHT + lines.offset
|
|
|
|
// 浏览器自己滚的时候(输入框拿到焦点、打字跟着光标走)也让开筛选栏和钉着的几行:问 AI 的输入框、块的编辑框不被盖住
|
|
useLayoutEffect(() => {
|
|
const page = document.documentElement
|
|
page.style.scrollPaddingTop = `${top + height}px`
|
|
return () => {
|
|
page.style.scrollPaddingTop = ''
|
|
}
|
|
}, [top, height])
|
|
|
|
if (count === 0)
|
|
return null
|
|
|
|
const jump = (id: string): void => {
|
|
const card = content.current?.querySelector<HTMLElement>(`.cr-block[data-id="${id}"]`)
|
|
if (!card)
|
|
return
|
|
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pinnedHeight() - stickyInset(card, settings))
|
|
flash(card)
|
|
}
|
|
|
|
return (
|
|
<TextMarksContext value={unhighlighted}>
|
|
<div className="cr-sticky" style={{ top, height }}>
|
|
{lines.scopes.map((scope, index) => {
|
|
const block = byId.get(scope.id)
|
|
const last = index === count - 1
|
|
return block && (
|
|
<div
|
|
key={scope.id}
|
|
className="cr-sticky-line"
|
|
data-last={last ? '' : undefined}
|
|
style={{ top: index * STICKY_LINE_HEIGHT + (last ? lines.offset : 0), height: STICKY_LINE_HEIGHT }}
|
|
onClick={() => jump(scope.id)}
|
|
>
|
|
<div className="h-full" style={{ marginLeft: scope.left, width: scope.right - scope.left }}>
|
|
<PinnedHead block={block} />
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</TextMarksContext>
|
|
)
|
|
}
|