// @vitest-environment happy-dom import type { Placement } from './placement' import type { BlockBox } from './scrollPosition' import { afterEach, describe, expect, it } from 'vitest' import { PageLayout } from './pageLayout' import { anchorAt, blockForLine } from './scrollPosition' /** 量过几次位置 */ let reads = 0 /** 元素的位置写死(happy-dom 不排版),每量一次记一次 */ function place(el: T, top: number, bottom: number, left = 0, right = 800): T { el.getBoundingClientRect = () => { reads++ return { top, bottom, left, right, width: right - left, height: bottom - top, x: left, y: top, toJSON: () => ({}) } as DOMRect } return el } function div(className: string, attrs: Record = {}, ...children: HTMLElement[]): HTMLElement { const el = document.createElement('div') el.className = className for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value) el.append(...children) return el } /** 一层:左边竖线那格、右边一栏 */ function level(owner: string | undefined, ...children: HTMLElement[]): HTMLElement { return div('cr-level', owner === undefined ? {} : { 'data-owner': owner }, div('guide'), div('column', {}, ...children)) } /** 各块在文件里的起始行(页面上不记行号,按块 id 从文档里各块的位置取) */ const LINES: Record = {} const PLACEMENT: Placement = { of: id => id in LINES ? { offset: 0, line: LINES[id], endLine: LINES[id] } : undefined } function card(id: string, line: number, top: number, bottom: number, editing = false): HTMLElement { LINES[id] = line return place(div('cr-block', { 'data-id': id, ...(editing && { 'data-editing': '' }) }), top, bottom) } /** * 三段顶层块,每段 1000px:第 0 段一张卡片和它的两张子卡片(子层到 900 结束),第 1 段一张卡片(在编辑),第 2 段一张卡片。 */ function page(): { root: HTMLElement, cards: BlockBox[] } { const root = document.createElement('div') root.append(level( undefined, place(div('cr-item', {}, card('0', 0, 0, 100), place(level('0', card('0.0', 2, 120, 300), card('0.1', 8, 320, 500)), 110, 900)), 0, 1000), place(div('cr-item', {}, card('1', 20, 1010, 1500, true)), 1010, 1500), place(div('cr-item', {}, card('2', 40, 1510, 2000)), 1510, 2000), )) document.body.append(root) const cards = [[0, 0, 100], [2, 120, 300], [8, 320, 500], [20, 1010, 1500], [40, 1510, 2000]].map(([line, top, bottom]) => ({ line, top, bottom })) return { root, cards } } afterEach(() => { document.body.innerHTML = '' reads = 0 }) describe('卡片位置量过就记着', () => { it('滚动位置、包含某一行的块:和把全部卡片量一遍算出来的一样', () => { const { root, cards } = page() const layout = new PageLayout() layout.attach(root, null, () => PLACEMENT) for (const y of [0, 50, 100, 310, 950, 1200, 1999, 2500]) { const found = layout.anchorAt(y) expect(found && { line: found.line, offset: found.offset }, `y ${y}`).toEqual((({ line, offset }) => ({ line, offset }))(anchorAt(cards, y)!)) } for (const line of [0, 1, 5, 9, 25, 100]) expect(layout.cardForLine(line)?.line, `line ${line}`).toBe(blockForLine(cards, line)?.line) expect(layout.topFor({ line: 8, offset: 5, scrollTop: 9999 })).toBe(325) expect(layout.topFor({ line: 9, offset: 5, scrollTop: 9999 })).toBe(9999) }) it('钉住的卡片:只算和顶上那一段相交的顶层块,在编辑的不钉;外层卡片的下沿算到它的子层', () => { const { root } = page() const layout = new PageLayout() layout.attach(root, null, () => PLACEMENT) window.scrollTo(0, 0) const scopes = layout.stickyScopes(48) expect(scopes.map(scope => [scope.id, scope.depth, scope.top, scope.bottom])).toEqual([['0', 0, 0, 900]]) }) it('滚动时不再量:同一份内容问几次都只量一次;只量用到的那几段;内容变了才重量', async () => { const { root } = page() const layout = new PageLayout() layout.attach(root, null, () => PLACEMENT) layout.anchorAt(0) // 三段顶层块的外框 + 第 0、1 段里的卡片和子层 const first = reads expect(first).toBe(3 + 4 + 1) for (const y of [0, 10, 20, 300, 600]) layout.anchorAt(y) layout.stickyScopes(48) expect(reads).toBe(first) root.querySelector('.cr-item')!.append(div('cr-qa')) await Promise.resolve() layout.anchorAt(0) expect(reads).toBe(first * 2) }) it('要滚过去的:目标所在的顶层块连同上下几屏之内的先照常排版,过两帧放回去', async () => { const { root } = page() const layout = new PageLayout() layout.attach(root, null, () => PLACEMENT) const items = [...root.querySelectorAll('.cr-item')] expect(layout.cardForLine(40)?.line).toBe(40) expect(items.map(item => item.hasAttribute('data-settle'))).toEqual([true, true, true]) await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve)))) expect(items.some(item => item.hasAttribute('data-settle'))).toBe(false) }) })