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 {
|
export {
|
||||||
fitColumns,
|
fitColumns,
|
||||||
type FitColumnsInput,
|
type FitColumnsInput,
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export const BOARD_SCHEMA_VERSION = 1;
|
|||||||
*/
|
*/
|
||||||
export const MAX_COLUMNS = 3;
|
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
|
* Default shared specimen — one header line + one body paragraph (single
|
||||||
* language). Used to seed the board and as the share-state fallback.
|
* 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 => {
|
vi.mock('$entities/Font', async importOriginal => {
|
||||||
const actual = await importOriginal<typeof import('$entities/Font')>();
|
const actual = await importOriginal<typeof import('$entities/Font')>();
|
||||||
class MockFontsByIdsStore {
|
class MockFontsByIdsStore {
|
||||||
setIds() {}
|
setIds() {}
|
||||||
get fonts() {
|
get fonts() {
|
||||||
return [];
|
return mockFonts;
|
||||||
}
|
}
|
||||||
get isLoading() {
|
get isLoading() {
|
||||||
return false;
|
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 { flushSync } from 'svelte';
|
||||||
import {
|
import {
|
||||||
__resetBoard,
|
__resetBoard,
|
||||||
@@ -61,6 +77,7 @@ import {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
mockFonts.length = 0;
|
||||||
__resetBoard();
|
__resetBoard();
|
||||||
});
|
});
|
||||||
afterEach(() => __resetBoard());
|
afterEach(() => __resetBoard());
|
||||||
@@ -159,6 +176,16 @@ describe('boardStore', () => {
|
|||||||
expect(restored.pairings[0].id).toBe(p.id);
|
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', () => {
|
it('collects every distinct candidate font id for preloading', () => {
|
||||||
const board = getBoard();
|
const board = getBoard();
|
||||||
board.addPairing('Inter', 'Lora');
|
board.addPairing('Inter', 'Lora');
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
* values, fed in via `setTypo` by `widgets/Board` (dependency inversion).
|
* values, fed in via `setTypo` by `widgets/Board` (dependency inversion).
|
||||||
*
|
*
|
||||||
* Font metadata is resolved + preloaded via the Font entity (candidate
|
* 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 {
|
import {
|
||||||
@@ -30,22 +31,32 @@ import {
|
|||||||
import {
|
import {
|
||||||
type Pairing,
|
type Pairing,
|
||||||
type Role,
|
type Role,
|
||||||
|
comboKey,
|
||||||
createPairing,
|
createPairing,
|
||||||
nextFocalId,
|
nextFocalId,
|
||||||
} from '$entities/Pairing';
|
} from '$entities/Pairing';
|
||||||
|
import {
|
||||||
|
combineFrameHeight,
|
||||||
|
measureRoleHeight,
|
||||||
|
} from '$features/CompareBoard/lib/measure';
|
||||||
import {
|
import {
|
||||||
BOARD_SCHEMA_VERSION,
|
BOARD_SCHEMA_VERSION,
|
||||||
BOARD_STORAGE_KEY,
|
BOARD_STORAGE_KEY,
|
||||||
DEFAULT_SPECIMEN,
|
DEFAULT_SPECIMEN,
|
||||||
|
FRAME_ROLE_GAP,
|
||||||
} from '$features/CompareBoard/model/const/const';
|
} from '$features/CompareBoard/model/const/const';
|
||||||
import {
|
import {
|
||||||
createPersistentStore,
|
createPersistentStore,
|
||||||
createSingleton,
|
createSingleton,
|
||||||
|
ensureCanvasFonts,
|
||||||
|
getPretextFontString,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
|
import { prepareWithSegments } from '@chenglou/pretext';
|
||||||
import {
|
import {
|
||||||
flushSync,
|
flushSync,
|
||||||
untrack,
|
untrack,
|
||||||
} from 'svelte';
|
} from 'svelte';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact persisted board shape. Font ids are abbreviated (`h`/`b`) to keep the
|
* 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).
|
* construction (never re-seed after the user empties the board).
|
||||||
*/
|
*/
|
||||||
#shouldSeed: boolean;
|
#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.
|
* 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
|
* Adds a pairing to the end of the board. The first pairing added becomes
|
||||||
* focal.
|
* focal.
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ export {
|
|||||||
clampNumber,
|
clampNumber,
|
||||||
cn,
|
cn,
|
||||||
debounce,
|
debounce,
|
||||||
|
ensureCanvasFonts,
|
||||||
getDecimalPlaces,
|
getDecimalPlaces,
|
||||||
|
getPretextFontString,
|
||||||
roundToStepPrecision,
|
roundToStepPrecision,
|
||||||
smoothScroll,
|
smoothScroll,
|
||||||
splitArray,
|
splitArray,
|
||||||
|
|||||||
Reference in New Issue
Block a user