feat(CompareBoard): measure focal frame height and expose slice public API
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export { fitColumns } from './lib';
|
||||
export {
|
||||
__resetBoard,
|
||||
type BoardStore,
|
||||
getBoard,
|
||||
MAX_COLUMNS,
|
||||
type RoleTypography,
|
||||
} from './model';
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
combineFrameHeight,
|
||||
type CombineFrameHeightInput,
|
||||
fitColumns,
|
||||
type FitColumnsInput,
|
||||
measureRoleHeight,
|
||||
type RoleHeightInput,
|
||||
} from './measure';
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { combineFrameHeight } from './combineFrameHeight';
|
||||
|
||||
describe('combineFrameHeight', () => {
|
||||
it('sums header + gap + body block heights', () => {
|
||||
expect(combineFrameHeight({ headerHeight: 60, bodyHeight: 200, gap: 24 })).toBe(284);
|
||||
});
|
||||
it('omits the gap when one block is empty (zero height)', () => {
|
||||
expect(combineFrameHeight({ headerHeight: 0, bodyHeight: 200, gap: 24 })).toBe(200);
|
||||
expect(combineFrameHeight({ headerHeight: 60, bodyHeight: 0, gap: 24 })).toBe(60);
|
||||
});
|
||||
it('is zero when both blocks are empty', () => {
|
||||
expect(combineFrameHeight({ headerHeight: 0, bodyHeight: 0, gap: 24 })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Inputs for combining a frame's two role blocks into one height.
|
||||
*/
|
||||
export interface CombineFrameHeightInput {
|
||||
/**
|
||||
* Measured header block height in px.
|
||||
*/
|
||||
headerHeight: number;
|
||||
/**
|
||||
* Measured body block height in px.
|
||||
*/
|
||||
bodyHeight: number;
|
||||
/**
|
||||
* Gap in px between the header and body blocks.
|
||||
*/
|
||||
gap: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total focal-frame height: header block + gap + body block. The gap only
|
||||
* applies when both blocks have height — an empty role (no specimen text)
|
||||
* contributes neither height nor a dangling gap.
|
||||
*
|
||||
* @param input - The two block heights and the inter-block gap.
|
||||
* @returns The combined frame height in px.
|
||||
*/
|
||||
export function combineFrameHeight({ headerHeight, bodyHeight, gap }: CombineFrameHeightInput): number {
|
||||
const gapApplies = headerHeight > 0 && bodyHeight > 0;
|
||||
return headerHeight + bodyHeight + (gapApplies ? gap : 0);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
export {
|
||||
combineFrameHeight,
|
||||
type CombineFrameHeightInput,
|
||||
} from './combineFrameHeight';
|
||||
export {
|
||||
fitColumns,
|
||||
type FitColumnsInput,
|
||||
|
||||
@@ -24,6 +24,13 @@ export const BOARD_SCHEMA_VERSION = 1;
|
||||
*/
|
||||
export const MAX_COLUMNS = 3;
|
||||
|
||||
/**
|
||||
* Vertical gap in px between the header block and the body block within a frame.
|
||||
* Used by frame-height measurement so the reserved height matches the rendered
|
||||
* layout exactly (zero-shift).
|
||||
*/
|
||||
export const FRAME_ROLE_GAP = 24;
|
||||
|
||||
/**
|
||||
* Default shared specimen — one header line + one body paragraph (single
|
||||
* language). Used to seed the board and as the share-state fallback.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export { MAX_COLUMNS } from './const/const';
|
||||
export {
|
||||
__resetBoard,
|
||||
type BoardStore,
|
||||
getBoard,
|
||||
type RoleTypography,
|
||||
} from './store/boardStore/boardStore.svelte';
|
||||
@@ -32,12 +32,15 @@ const mockCatalog = vi.hoisted(() => ({
|
||||
],
|
||||
}));
|
||||
|
||||
/** Mutable resolved-font list the stubbed FontsByIdsStore returns; reset per test. */
|
||||
const mockFonts = vi.hoisted(() => [] as { id: string; name: string }[]);
|
||||
|
||||
vi.mock('$entities/Font', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('$entities/Font')>();
|
||||
class MockFontsByIdsStore {
|
||||
setIds() {}
|
||||
get fonts() {
|
||||
return [];
|
||||
return mockFonts;
|
||||
}
|
||||
get isLoading() {
|
||||
return false;
|
||||
@@ -53,6 +56,19 @@ vi.mock('$entities/Font', async importOriginal => {
|
||||
};
|
||||
});
|
||||
|
||||
// ensureCanvasFonts needs a real browser canvas; stub it to resolve immediately.
|
||||
// Spread actual so createPersistentStore/getPretextFontString stay real.
|
||||
vi.mock('$shared/lib', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('$shared/lib')>();
|
||||
return { ...actual, ensureCanvasFonts: vi.fn(() => Promise.resolve()) };
|
||||
});
|
||||
|
||||
// Pretext measures via canvas (degenerate in jsdom); stub for deterministic lines.
|
||||
vi.mock('@chenglou/pretext', () => ({
|
||||
prepareWithSegments: vi.fn(() => ({})),
|
||||
layout: vi.fn(() => ({ lineCount: 2, height: 0 })),
|
||||
}));
|
||||
|
||||
import { flushSync } from 'svelte';
|
||||
import {
|
||||
__resetBoard,
|
||||
@@ -61,6 +77,7 @@ import {
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockFonts.length = 0;
|
||||
__resetBoard();
|
||||
});
|
||||
afterEach(() => __resetBoard());
|
||||
@@ -159,6 +176,16 @@ describe('boardStore', () => {
|
||||
expect(restored.pairings[0].id).toBe(p.id);
|
||||
});
|
||||
|
||||
it('returns fallback 0 before warm, positive height once fonts resolve and warm', async () => {
|
||||
mockFonts.push({ id: 'Inter', name: 'Inter' }, { id: 'Lora', name: 'Lora' });
|
||||
const board = getBoard();
|
||||
const p = board.addPairing('Inter', 'Lora');
|
||||
// Cold: canvas not yet warm -> reserved fallback, never a cold measure.
|
||||
expect(board.frameHeight(p.id, 600)).toBe(0);
|
||||
await vi.waitFor(() => expect(board.measureReady).toBe(true));
|
||||
expect(board.frameHeight(p.id, 600)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('collects every distinct candidate font id for preloading', () => {
|
||||
const board = getBoard();
|
||||
board.addPairing('Inter', 'Lora');
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* values, fed in via `setTypo` by `widgets/Board` (dependency inversion).
|
||||
*
|
||||
* Font metadata is resolved + preloaded via the Font entity (candidate
|
||||
* preloading, focal pinning). Frame measurement (Task 16) layers on later.
|
||||
* preloading, focal pinning). Frame heights are Pretext-measured behind a
|
||||
* canvas warm-gate (the zero-shift core) and memoized for flicker-free cycling.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -30,22 +31,32 @@ import {
|
||||
import {
|
||||
type Pairing,
|
||||
type Role,
|
||||
comboKey,
|
||||
createPairing,
|
||||
nextFocalId,
|
||||
} from '$entities/Pairing';
|
||||
import {
|
||||
combineFrameHeight,
|
||||
measureRoleHeight,
|
||||
} from '$features/CompareBoard/lib/measure';
|
||||
import {
|
||||
BOARD_SCHEMA_VERSION,
|
||||
BOARD_STORAGE_KEY,
|
||||
DEFAULT_SPECIMEN,
|
||||
FRAME_ROLE_GAP,
|
||||
} from '$features/CompareBoard/model/const/const';
|
||||
import {
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
ensureCanvasFonts,
|
||||
getPretextFontString,
|
||||
} from '$shared/lib';
|
||||
import { prepareWithSegments } from '@chenglou/pretext';
|
||||
import {
|
||||
flushSync,
|
||||
untrack,
|
||||
} from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
/**
|
||||
* Compact persisted board shape. Font ids are abbreviated (`h`/`b`) to keep the
|
||||
@@ -158,6 +169,26 @@ export class BoardStore {
|
||||
* construction (never re-seed after the user empties the board).
|
||||
*/
|
||||
#shouldSeed: boolean;
|
||||
/**
|
||||
* Font strings whose canvas metrics are confirmed real (warm). Reactive
|
||||
* (SvelteSet) so a completed warm re-runs height readers. Gates `prepare()`
|
||||
* to avoid poisoning Pretext's cache with fallback widths.
|
||||
*/
|
||||
#warmed = new SvelteSet<string>();
|
||||
/**
|
||||
* Font strings with an in-flight `ensureCanvasFonts` — dedupes warm requests.
|
||||
*/
|
||||
#warming = new Set<string>();
|
||||
/**
|
||||
* Memoized frame heights keyed by (combo, width, specimen, typography), so
|
||||
* cycling back to a measured pairing is O(1) and never reflows.
|
||||
*/
|
||||
#heightCache = new Map<string, number>();
|
||||
/**
|
||||
* Last computed height per pairing — the reserved fallback returned while a
|
||||
* pairing's fonts load/warm, so the frame never collapses to 0 mid-cycle.
|
||||
*/
|
||||
#lastHeight = new Map<string, number>();
|
||||
/**
|
||||
* Disposes the constructor's $effect.root. Must run on teardown.
|
||||
*/
|
||||
@@ -359,6 +390,138 @@ export class BoardStore {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The focal frame's measured height at the given content width.
|
||||
*
|
||||
* @param contentWidth - The frame's content width in px.
|
||||
* @returns Height in px (0 when the board is empty).
|
||||
*/
|
||||
focalFrameHeight(contentWidth: number): number {
|
||||
return this.#focalId ? this.frameHeight(this.#focalId, contentWidth) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-measures a (typically next-up) pairing so cycling to it never reflows.
|
||||
*
|
||||
* @param pairingId - The pairing to measure ahead of time.
|
||||
* @param contentWidth - The frame's content width in px.
|
||||
* @returns Height in px (fallback while fonts load/warm).
|
||||
*/
|
||||
peekFrameHeight(pairingId: string, contentWidth: number): number {
|
||||
return this.frameHeight(pairingId, contentWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measured height of a pairing's frame (header block + gap + body block) at a
|
||||
* content width, via Pretext's pure line-count arithmetic. Returns the
|
||||
* last-known height (or 0) until both fonts are resolved AND the canvas is
|
||||
* warm — never measures cold, which would poison Pretext's width cache
|
||||
* forever. Results are memoized per (combo, width, specimen, typography).
|
||||
*
|
||||
* @param pairingId - The pairing to measure.
|
||||
* @param contentWidth - The frame's content width in px.
|
||||
* @returns Height in px.
|
||||
*/
|
||||
frameHeight(pairingId: string, contentWidth: number): number {
|
||||
const pairing = this.#pairings.find(p => p.id === pairingId);
|
||||
if (!pairing) {
|
||||
return 0;
|
||||
}
|
||||
const { header, body } = this.resolvePairingFonts(pairing);
|
||||
const fallback = this.#lastHeight.get(pairingId) ?? 0;
|
||||
if (!header || !body) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const headerFont = getPretextFontString(this.#typo.header.weight, this.#typo.header.size, header.name);
|
||||
const bodyFont = getPretextFontString(this.#typo.body.weight, this.#typo.body.size, body.name);
|
||||
|
||||
this.#ensureWarm([headerFont, bodyFont]);
|
||||
// SvelteSet read is reactive: a completed warm re-runs height readers.
|
||||
if (!this.#warmed.has(headerFont) || !this.#warmed.has(bodyFont)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const key = `${comboKey(pairing)}|${contentWidth}|${this.#specimen.header}|${this.#specimen.body}|`
|
||||
+ this.#typoSignature();
|
||||
const cached = this.#heightCache.get(key);
|
||||
if (cached !== undefined) {
|
||||
this.#lastHeight.set(pairingId, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const headerHeight = measureRoleHeight({
|
||||
prepared: prepareWithSegments(this.#specimen.header, headerFont, {
|
||||
letterSpacing: this.#typo.header.tracking,
|
||||
}),
|
||||
maxWidth: contentWidth,
|
||||
sizePx: this.#typo.header.size,
|
||||
lineHeight: this.#typo.header.leading,
|
||||
});
|
||||
const bodyHeight = measureRoleHeight({
|
||||
prepared: prepareWithSegments(this.#specimen.body, bodyFont, {
|
||||
letterSpacing: this.#typo.body.tracking,
|
||||
}),
|
||||
maxWidth: contentWidth,
|
||||
sizePx: this.#typo.body.size,
|
||||
lineHeight: this.#typo.body.leading,
|
||||
});
|
||||
const height = combineFrameHeight({ headerHeight, bodyHeight, gap: FRAME_ROLE_GAP });
|
||||
this.#heightCache.set(key, height);
|
||||
this.#lastHeight.set(pairingId, height);
|
||||
return height;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the focal pairing's fonts are resolved and canvas-warm — the UI
|
||||
* gates the first paint of the focal frame on this to avoid a cold-measure
|
||||
* flash.
|
||||
*/
|
||||
get measureReady(): boolean {
|
||||
const focal = this.focal;
|
||||
if (!focal) {
|
||||
return false;
|
||||
}
|
||||
const { header, body } = this.resolvePairingFonts(focal);
|
||||
if (!header || !body) {
|
||||
return false;
|
||||
}
|
||||
const headerFont = getPretextFontString(this.#typo.header.weight, this.#typo.header.size, header.name);
|
||||
const bodyFont = getPretextFontString(this.#typo.body.weight, this.#typo.body.size, body.name);
|
||||
return this.#warmed.has(headerFont) && this.#warmed.has(bodyFont);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kicks off canvas warming for any cold font strings (dedup'd). Fire-and-
|
||||
* forget: on resolution the strings join `#warmed`, re-running height readers.
|
||||
*/
|
||||
#ensureWarm(fontStrings: string[]) {
|
||||
const cold = fontStrings.filter(s => !this.#warmed.has(s) && !this.#warming.has(s));
|
||||
if (cold.length === 0) {
|
||||
return;
|
||||
}
|
||||
cold.forEach(s => this.#warming.add(s));
|
||||
void ensureCanvasFonts(cold)
|
||||
.then(() => {
|
||||
cold.forEach(s => {
|
||||
this.#warming.delete(s);
|
||||
this.#warmed.add(s);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
cold.forEach(s => this.#warming.delete(s));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of both roles' typography, for the height memo key.
|
||||
*/
|
||||
#typoSignature(): string {
|
||||
const h = this.#typo.header;
|
||||
const b = this.#typo.body;
|
||||
return `${h.size},${h.weight},${h.leading},${h.tracking};${b.size},${b.weight},${b.leading},${b.tracking}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a pairing to the end of the board. The first pairing added becomes
|
||||
* focal.
|
||||
|
||||
@@ -33,7 +33,9 @@ export {
|
||||
clampNumber,
|
||||
cn,
|
||||
debounce,
|
||||
ensureCanvasFonts,
|
||||
getDecimalPlaces,
|
||||
getPretextFontString,
|
||||
roundToStepPrecision,
|
||||
smoothScroll,
|
||||
splitArray,
|
||||
|
||||
Reference in New Issue
Block a user