Merge pull request 'Fix/text morphing position' (#40) from fix/text-morphing-position into main
Reviewed-on: #40
This commit was merged in pull request #40.
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
export * from './utils/dotTransition';
|
export * from './utils/dotTransition';
|
||||||
export * from './utils/getPretextFontString';
|
export * from './utils/ensureCanvasFonts/ensureCanvasFonts';
|
||||||
|
export * from './utils/getPretextFontString/getPretextFontString';
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { getPretextFontString } from '../getPretextFontString/getPretextFontString';
|
||||||
|
import { ensureCanvasFonts } from './ensureCanvasFonts';
|
||||||
|
|
||||||
|
const FALLBACK_FAMILY = '__glyphdiff_no_such_font_42__';
|
||||||
|
const fallbackFont = (sizePx: number) => getPretextFontString(400, sizePx, FALLBACK_FAMILY);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake Canvas2D context that returns a scripted width per font string.
|
||||||
|
* Tracks how many times measureText was called so tests can assert polling
|
||||||
|
* behavior without depending on wall-clock time.
|
||||||
|
*/
|
||||||
|
function createFakeCtx() {
|
||||||
|
const widthsByFont = new Map<string, number | (() => number)>();
|
||||||
|
const measureCalls: Array<{ font: string; text: string }> = [];
|
||||||
|
const ctx = {
|
||||||
|
font: '',
|
||||||
|
measureText(text: string) {
|
||||||
|
measureCalls.push({ font: ctx.font, text });
|
||||||
|
const entry = widthsByFont.get(ctx.font);
|
||||||
|
const width = typeof entry === 'function' ? entry() : entry ?? 0;
|
||||||
|
return { width };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ctx: ctx as unknown as CanvasRenderingContext2D,
|
||||||
|
widthsByFont,
|
||||||
|
measureCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockGlobals {
|
||||||
|
fontsLoad: ReturnType<typeof vi.fn>;
|
||||||
|
rafCalls: number;
|
||||||
|
nowValues: number[];
|
||||||
|
nowIndex: { current: number };
|
||||||
|
restore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installGlobals(opts: {
|
||||||
|
/** Sequence of values returned by performance.now(); last value repeats. */
|
||||||
|
nowSequence: number[];
|
||||||
|
/** If true, OffscreenCanvas is defined and getContext returns the fake ctx. */
|
||||||
|
useOffscreenCanvas: boolean;
|
||||||
|
ctx: CanvasRenderingContext2D | null;
|
||||||
|
}): MockGlobals {
|
||||||
|
const fontsLoad = vi.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
const originals: Array<[string, PropertyDescriptor | undefined]> = [];
|
||||||
|
const setGlobal = (key: string, value: unknown) => {
|
||||||
|
originals.push([key, Object.getOwnPropertyDescriptor(globalThis, key)]);
|
||||||
|
Object.defineProperty(globalThis, key, {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setGlobal('document', {
|
||||||
|
fonts: { load: fontsLoad },
|
||||||
|
createElement: vi.fn(() => ({
|
||||||
|
getContext: vi.fn(() => opts.ctx),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (opts.useOffscreenCanvas) {
|
||||||
|
class FakeOffscreenCanvas {
|
||||||
|
getContext() {
|
||||||
|
return opts.ctx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setGlobal('OffscreenCanvas', FakeOffscreenCanvas);
|
||||||
|
} else {
|
||||||
|
setGlobal('OffscreenCanvas', undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowIndex = { current: 0 };
|
||||||
|
setGlobal('performance', {
|
||||||
|
now: () => opts.nowSequence[Math.min(nowIndex.current++, opts.nowSequence.length - 1)],
|
||||||
|
});
|
||||||
|
|
||||||
|
let rafCount = 0;
|
||||||
|
const rafState = { count: 0 };
|
||||||
|
setGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||||
|
rafState.count++;
|
||||||
|
rafCount++;
|
||||||
|
Promise.resolve().then(() => cb(0));
|
||||||
|
return rafCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
fontsLoad,
|
||||||
|
get rafCalls() {
|
||||||
|
return rafState.count;
|
||||||
|
},
|
||||||
|
nowValues: opts.nowSequence,
|
||||||
|
nowIndex,
|
||||||
|
restore() {
|
||||||
|
for (const [key, desc] of originals) {
|
||||||
|
if (desc) {
|
||||||
|
Object.defineProperty(globalThis, key, desc);
|
||||||
|
} else {
|
||||||
|
delete (globalThis as any)[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as MockGlobals;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ensureCanvasFonts', () => {
|
||||||
|
let cleanup: (() => void) | undefined;
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup?.();
|
||||||
|
cleanup = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('awaits document.fonts.load for every font string', async () => {
|
||||||
|
const { ctx, widthsByFont } = createFakeCtx();
|
||||||
|
const fontA = getPretextFontString(400, 36, 'Roboto');
|
||||||
|
const fontB = getPretextFontString(400, 36, 'Smooch Sans');
|
||||||
|
// Real font width clearly differs from the unknown-family fallback width.
|
||||||
|
widthsByFont.set(fontA, 200);
|
||||||
|
widthsByFont.set(fontB, 130);
|
||||||
|
// The fallback probe uses the same size with an unknown family.
|
||||||
|
widthsByFont.set(fallbackFont(36), 280);
|
||||||
|
|
||||||
|
const mocks = installGlobals({
|
||||||
|
nowSequence: [0, 5],
|
||||||
|
useOffscreenCanvas: true,
|
||||||
|
ctx,
|
||||||
|
});
|
||||||
|
cleanup = mocks.restore;
|
||||||
|
|
||||||
|
await ensureCanvasFonts([fontA, fontB]);
|
||||||
|
|
||||||
|
expect(mocks.fontsLoad).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.fontsLoad).toHaveBeenCalledWith(fontA);
|
||||||
|
expect(mocks.fontsLoad).toHaveBeenCalledWith(fontB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns without polling when fonts already measure as non-fallback', async () => {
|
||||||
|
const { ctx, widthsByFont } = createFakeCtx();
|
||||||
|
const font = getPretextFontString(400, 36, 'Roboto');
|
||||||
|
widthsByFont.set(font, 200);
|
||||||
|
widthsByFont.set(fallbackFont(36), 280);
|
||||||
|
|
||||||
|
const mocks = installGlobals({
|
||||||
|
nowSequence: [0, 5],
|
||||||
|
useOffscreenCanvas: true,
|
||||||
|
ctx,
|
||||||
|
});
|
||||||
|
cleanup = mocks.restore;
|
||||||
|
|
||||||
|
await ensureCanvasFonts([font]);
|
||||||
|
|
||||||
|
// First iteration succeeds → no rAF needed
|
||||||
|
expect(mocks.rafCalls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polls via requestAnimationFrame until measurement diverges from fallback', async () => {
|
||||||
|
const { ctx, widthsByFont, measureCalls } = createFakeCtx();
|
||||||
|
const font = getPretextFontString(400, 36, 'Roboto');
|
||||||
|
widthsByFont.set(fallbackFont(36), 280);
|
||||||
|
// Roboto reports the fallback width for the first two reads, then resolves.
|
||||||
|
let robotoReads = 0;
|
||||||
|
widthsByFont.set(font, () => {
|
||||||
|
robotoReads++;
|
||||||
|
return robotoReads <= 2 ? 280 : 200;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide enough now() values for: initial fallback measurement +
|
||||||
|
// multiple loop iterations within the deadline.
|
||||||
|
const mocks = installGlobals({
|
||||||
|
nowSequence: [0, 10, 20, 30, 40, 50],
|
||||||
|
useOffscreenCanvas: true,
|
||||||
|
ctx,
|
||||||
|
});
|
||||||
|
cleanup = mocks.restore;
|
||||||
|
|
||||||
|
await ensureCanvasFonts([font]);
|
||||||
|
|
||||||
|
// Two iterations failed → two rAF awaits before success on the third.
|
||||||
|
expect(mocks.rafCalls).toBe(2);
|
||||||
|
// Measurement was called once for the fallback probe + three poll attempts.
|
||||||
|
const robotoCalls = measureCalls.filter(c => c.font === font).length;
|
||||||
|
expect(robotoCalls).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits when performance.now passes the 1s deadline even if fonts never load', async () => {
|
||||||
|
const { ctx, widthsByFont } = createFakeCtx();
|
||||||
|
const font = getPretextFontString(400, 36, 'NeverLoads');
|
||||||
|
widthsByFont.set(fallbackFont(36), 280);
|
||||||
|
// Always returns fallback width → poll never finds a divergence.
|
||||||
|
widthsByFont.set(font, 280);
|
||||||
|
|
||||||
|
const mocks = installGlobals({
|
||||||
|
// Start at 0, then the next check jumps past the 1000ms deadline.
|
||||||
|
nowSequence: [0, 0, 1001],
|
||||||
|
useOffscreenCanvas: true,
|
||||||
|
ctx,
|
||||||
|
});
|
||||||
|
cleanup = mocks.restore;
|
||||||
|
|
||||||
|
await expect(ensureCanvasFonts([font])).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns early when no canvas context is available', async () => {
|
||||||
|
const mocks = installGlobals({
|
||||||
|
nowSequence: [0],
|
||||||
|
useOffscreenCanvas: false,
|
||||||
|
ctx: null,
|
||||||
|
});
|
||||||
|
cleanup = mocks.restore;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ensureCanvasFonts([getPretextFontString(400, 16, 'X')]),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
// fonts.load still ran; just no canvas polling.
|
||||||
|
expect(mocks.fontsLoad).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.rafCalls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a DOM canvas when OffscreenCanvas is unavailable', async () => {
|
||||||
|
const { ctx, widthsByFont } = createFakeCtx();
|
||||||
|
const font = getPretextFontString(400, 36, 'Roboto');
|
||||||
|
widthsByFont.set(font, 200);
|
||||||
|
widthsByFont.set(fallbackFont(36), 280);
|
||||||
|
|
||||||
|
const mocks = installGlobals({
|
||||||
|
nowSequence: [0, 5],
|
||||||
|
useOffscreenCanvas: false,
|
||||||
|
ctx,
|
||||||
|
});
|
||||||
|
cleanup = mocks.restore;
|
||||||
|
|
||||||
|
await ensureCanvasFonts([font]);
|
||||||
|
|
||||||
|
expect((globalThis as any).document.createElement).toHaveBeenCalledWith('canvas');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the font size from each font string for the fallback probe', async () => {
|
||||||
|
const { ctx, widthsByFont, measureCalls } = createFakeCtx();
|
||||||
|
const fontA = getPretextFontString(400, 24, 'FontA');
|
||||||
|
const fontB = getPretextFontString(700, 48, 'FontB');
|
||||||
|
widthsByFont.set(fallbackFont(24), 150);
|
||||||
|
widthsByFont.set(fallbackFont(48), 360);
|
||||||
|
widthsByFont.set(fontA, 100);
|
||||||
|
widthsByFont.set(fontB, 200);
|
||||||
|
|
||||||
|
const mocks = installGlobals({
|
||||||
|
nowSequence: [0, 5],
|
||||||
|
useOffscreenCanvas: true,
|
||||||
|
ctx,
|
||||||
|
});
|
||||||
|
cleanup = mocks.restore;
|
||||||
|
|
||||||
|
await ensureCanvasFonts([fontA, fontB]);
|
||||||
|
|
||||||
|
const fallbackFonts = measureCalls
|
||||||
|
.map(c => c.font)
|
||||||
|
.filter(f => f.includes(FALLBACK_FAMILY));
|
||||||
|
expect(fallbackFonts).toContain(fallbackFont(24));
|
||||||
|
expect(fallbackFonts).toContain(fallbackFont(48));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a font from the pending set as soon as it diverges, leaving others to poll', async () => {
|
||||||
|
const { ctx, widthsByFont, measureCalls } = createFakeCtx();
|
||||||
|
const fontA = getPretextFontString(400, 36, 'A');
|
||||||
|
const fontB = getPretextFontString(400, 36, 'B');
|
||||||
|
widthsByFont.set(fallbackFont(36), 280);
|
||||||
|
// A loads immediately; B takes one extra frame.
|
||||||
|
widthsByFont.set(fontA, 200);
|
||||||
|
let bReads = 0;
|
||||||
|
widthsByFont.set(fontB, () => {
|
||||||
|
bReads++;
|
||||||
|
return bReads === 1 ? 280 : 150;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mocks = installGlobals({
|
||||||
|
nowSequence: [0, 10, 20, 30, 40],
|
||||||
|
useOffscreenCanvas: true,
|
||||||
|
ctx,
|
||||||
|
});
|
||||||
|
cleanup = mocks.restore;
|
||||||
|
|
||||||
|
await ensureCanvasFonts([fontA, fontB]);
|
||||||
|
|
||||||
|
// A measured once (resolved iter 1). B measured twice (iter 1 fallback, iter 2 real).
|
||||||
|
const aCalls = measureCalls.filter(c => c.font === fontA).length;
|
||||||
|
const bCalls = measureCalls.filter(c => c.font === fontB).length;
|
||||||
|
expect(aCalls).toBe(1);
|
||||||
|
expect(bCalls).toBe(2);
|
||||||
|
expect(mocks.rafCalls).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Ensures a set of fonts is usable in a `<canvas>` measurement context.
|
||||||
|
*
|
||||||
|
* `document.fonts.load()` resolves once the FontFace bytes are fetched and
|
||||||
|
* parsed, but Chrome lazily registers fonts with the canvas measurement engine
|
||||||
|
* after that — `measureText` keeps returning a fallback width for some frames
|
||||||
|
* even though `document.fonts.check()` reports the font as loaded.
|
||||||
|
*
|
||||||
|
* Pretext caches measurements per font string forever, so a single fallback
|
||||||
|
* measurement during initial mount permanently poisons the cache and the
|
||||||
|
* comparison morph boundary drifts visibly from the thumb. This helper polls
|
||||||
|
* canvas measurement until each font reports a width that differs from the
|
||||||
|
* "unknown font family" fallback, guaranteeing the next `measureText` call
|
||||||
|
* sees the real glyph metrics.
|
||||||
|
*/
|
||||||
|
import { getPretextFontString } from '../getPretextFontString/getPretextFontString';
|
||||||
|
|
||||||
|
const PROBE_TEXT = 'mmmmmmmmmm';
|
||||||
|
const MAX_WAIT_MS = 1000;
|
||||||
|
const DEFAULT_PROBE_SIZE_PX = 16;
|
||||||
|
// Family unlikely to exist in any system — gives canvas's "unknown font" fallback width.
|
||||||
|
const FALLBACK_PROBE_FAMILY = '__glyphdiff_no_such_font_42__';
|
||||||
|
|
||||||
|
export async function ensureCanvasFonts(fontStrings: string[]): Promise<void> {
|
||||||
|
await Promise.all(fontStrings.map(f => document.fonts.load(f)));
|
||||||
|
|
||||||
|
// Pretext uses OffscreenCanvas when available; DOM canvas has separate font
|
||||||
|
// registration timing, so we MUST poll using the same canvas type pretext does.
|
||||||
|
const ctx = typeof OffscreenCanvas !== 'undefined'
|
||||||
|
? new OffscreenCanvas(1, 1).getContext('2d')
|
||||||
|
: document.createElement('canvas').getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure each font's "unknown font" fallback width (different per browser, per OS).
|
||||||
|
// Canvas uses this same fallback for any font family it can't resolve, so when the
|
||||||
|
// requested font finally registers, measureText will return a non-fallback width.
|
||||||
|
const fallbackWidths = new Map<string, number>();
|
||||||
|
for (const font of fontStrings) {
|
||||||
|
const sizeMatch = font.match(/(\d+(?:\.\d+)?)px/);
|
||||||
|
const sizePx = sizeMatch ? parseFloat(sizeMatch[1]) : DEFAULT_PROBE_SIZE_PX;
|
||||||
|
ctx.font = getPretextFontString(400, sizePx, FALLBACK_PROBE_FAMILY);
|
||||||
|
fallbackWidths.set(font, ctx.measureText(PROBE_TEXT).width);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = performance.now() + MAX_WAIT_MS;
|
||||||
|
const pending = new Set(fontStrings);
|
||||||
|
while (pending.size > 0 && performance.now() < deadline) {
|
||||||
|
for (const font of Array.from(pending)) {
|
||||||
|
ctx.font = font;
|
||||||
|
const w = ctx.measureText(PROBE_TEXT).width;
|
||||||
|
if (Math.abs(w - fallbackWidths.get(font)!) > 0.5) {
|
||||||
|
pending.delete(font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pending.size === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,10 @@ import { Loader } from '$shared/ui';
|
|||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { Spring } from 'svelte/motion';
|
import { Spring } from 'svelte/motion';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { getPretextFontString } from '../../lib';
|
import {
|
||||||
|
ensureCanvasFonts,
|
||||||
|
getPretextFontString,
|
||||||
|
} from '../../lib';
|
||||||
import { comparisonStore } from '../../model';
|
import { comparisonStore } from '../../model';
|
||||||
import Character from '../Character/Character.svelte';
|
import Character from '../Character/Character.svelte';
|
||||||
import Line from '../Line/Line.svelte';
|
import Line from '../Line/Line.svelte';
|
||||||
@@ -144,7 +147,10 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Layout effect — depends on content, settings AND containerWidth
|
// Layout effect — depends on content, settings AND containerWidth.
|
||||||
|
// Awaits font loading into the canvas measurement context before invoking
|
||||||
|
// the engine; otherwise pretext caches fallback-font widths globally per
|
||||||
|
// font string, and the morph boundary drifts from the thumb visually.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const _text = comparisonStore.text;
|
const _text = comparisonStore.text;
|
||||||
const _weight = typography.weight;
|
const _weight = typography.weight;
|
||||||
@@ -154,8 +160,10 @@ $effect(() => {
|
|||||||
const _width = containerWidth;
|
const _width = containerWidth;
|
||||||
const _isMobile = isMobile;
|
const _isMobile = isMobile;
|
||||||
|
|
||||||
if (container && fontA && fontB && _width > 0) {
|
if (!container || !fontA || !fontB || _width <= 0) {
|
||||||
// PRETEXT API strings: "weight sizepx family"
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fontAStr = getPretextFontString(_weight, _size, fontA.name);
|
const fontAStr = getPretextFontString(_weight, _size, fontA.name);
|
||||||
const fontBStr = getPretextFontString(_weight, _size, fontB.name);
|
const fontBStr = getPretextFontString(_weight, _size, fontB.name);
|
||||||
|
|
||||||
@@ -163,6 +171,11 @@ $effect(() => {
|
|||||||
const availableWidth = Math.max(0, _width - padding);
|
const availableWidth = Math.max(0, _width - padding);
|
||||||
const lineHeight = _size * _height;
|
const lineHeight = _size * _height;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
ensureCanvasFonts([fontAStr, fontBStr]).then(() => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
layoutResult = comparisonEngine.layout(
|
layoutResult = comparisonEngine.layout(
|
||||||
_text,
|
_text,
|
||||||
fontAStr,
|
fontAStr,
|
||||||
@@ -172,7 +185,11 @@ $effect(() => {
|
|||||||
_spacing,
|
_spacing,
|
||||||
_size,
|
_size,
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dynamic backgroundSize based on isMobile — can't express this in Tailwind.
|
// Dynamic backgroundSize based on isMobile — can't express this in Tailwind.
|
||||||
|
|||||||
Reference in New Issue
Block a user