Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/studio/src/components/editor/overlayFrameLoop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
IDLE_POLL_MS,
IDLE_SLICE_MS,
requestOverlayFrames,
resetOverlayFrameLoopForTests,
runWhenInputIdle,
subscribeOverlayFrame,
} from "./overlayFrameLoop";
import { usePlayerStore } from "../../player/store/playerStore";
Expand Down Expand Up @@ -71,6 +73,31 @@ describe("overlay frame loop", () => {
expect(runs).toBeGreaterThan(0);
});

it("runs idle work in capped slices, only while no input arrives", () => {
const budgets: number[] = [];
requestOverlayFrames();
runWhenInputIdle((timeLeft) => {
budgets.push(timeLeft());
return budgets.length === 3;
});
vi.advanceTimersByTime(300);
expect(budgets).toEqual([]);
vi.advanceTimersByTime(200);
expect(budgets).toHaveLength(3);
expect(Math.max(...budgets)).toBeLessThanOrEqual(IDLE_SLICE_MS);

// Input during a slice holds the next one until the loop has been idle again.
const slices: number[] = [];
runWhenInputIdle(() => {
slices.push(performance.now());
if (slices.length === 1) requestOverlayFrames();
return slices.length === 2;
});
vi.advanceTimersByTime(1000);
expect(slices).toHaveLength(2);
expect(slices[1] - slices[0]).toBeGreaterThanOrEqual(400);
});

it("runs every subscriber on one frame, not one frame each", () => {
const calls: string[] = [];
subscribeOverlayFrame(() => calls.push("a"));
Expand Down
21 changes: 21 additions & 0 deletions packages/studio/src/components/editor/overlayFrameLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,27 @@ function schedule(): void {
}, IDLE_POLL_MS);
}

/** Longest idle slice, so input that arrives during one waits at most this long for its frame. */
export const IDLE_SLICE_MS = 3;

/**
* Runs `step` in idle slices once no input has woken the loop for AWAKE_MS, so it never shares a gesture's
* frames. `step` works while `timeLeft()` is positive and returns true when it has finished.
*/
export function runWhenInputIdle(step: (timeLeft: () => number) => boolean): void {
const wait = awakeUntil - performance.now();
if (wait > 0) {
setTimeout(() => runWhenInputIdle(step), wait);
return;
}
const idle = window.requestIdleCallback ?? ((callback: () => void) => setTimeout(callback, 0));
idle(() => {
const end = performance.now() + IDLE_SLICE_MS;
if (performance.now() < awakeUntil || !step(() => end - performance.now()))
runWhenInputIdle(step);
});
}

/** Something moved, or might have. Run frames at full rate for a moment. */
export function requestOverlayFrames(): void {
awakeUntil = performance.now() + AWAKE_MS;
Expand Down
83 changes: 80 additions & 3 deletions packages/studio/src/components/editor/propertyPanelFont.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { sortFontOptions } from "./propertyPanelHelpers";

vi.mock("./propertyPanelHelpers", async (importOriginal) => {
const actual = await importOriginal<typeof import("./propertyPanelHelpers")>();
return { ...actual, sortFontOptions: vi.fn(actual.sortFontOptions) };
return {
...actual,
sortFontOptions: vi.fn(actual.sortFontOptions),
uniqueFontFamilies: vi.fn(actual.uniqueFontFamilies),
};
});

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Expand Down Expand Up @@ -56,13 +60,14 @@ describe("FontFamilyField font list", () => {
root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
try {
await act(async () => render("Arial"));
// The font lists arrive and the value changes while the dropdown is closed, as during a drag.
// The value changes while the dropdown is closed, as during a drag.
await act(async () => render("Georgia"));
expect(sortFontOptions).not.toHaveBeenCalled();

const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
await act(async () => trigger?.click());
expect(sortFontOptions).toHaveBeenCalledTimes(1);
await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
expect(sortFontOptions).toHaveBeenCalled();
expect(host.textContent).toContain("Roboto Slab");
expect(document.head.querySelector('link[href*="fonts.googleapis.com"]')).toBeNull();
} finally {
Expand All @@ -71,3 +76,75 @@ describe("FontFamilyField font list", () => {
}
});
});

describe("FontFamilyField session font lists", () => {
const fresh = async () => {
vi.resetModules();
const { FontFamilyField } = await import("./propertyPanelFont");
const { uniqueFontFamilies } = await import("./propertyPanelHelpers");
const loop = await import("./overlayFrameLoop");
vi.mocked(uniqueFontFamilies).mockClear();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (value: string) =>
root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
const toggle = () =>
host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]')?.click();
const done = () => {
act(() => root.unmount());
host.remove();
loop.resetOverlayFrameLoopForTests();
};
return { uniqueFontFamilies, loop, host, render, toggle, done };
};

it("does no list work in a drag's frames when the lists land mid-drag", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance", "Date"] });
const raf = window.requestAnimationFrame;
window.requestAnimationFrame = (() => 0) as typeof window.requestAnimationFrame;
const lists = vi.mocked(fetch).getMockImplementation()!;
vi.mocked(fetch).mockImplementation(async (url) => {
await new Promise((resolve) => setTimeout(resolve, 200));
return lists(url);
});
const t = await fresh();
const stopLoop = t.loop.subscribeOverlayFrame(() => undefined);
try {
await act(async () => t.render("Georgia"));
for (let elapsed = 0; elapsed < 600; elapsed += 16) {
window.dispatchEvent(new Event("pointermove"));
await act(async () => vi.advanceTimersByTimeAsync(16));
}
expect(fetch).toHaveBeenCalledTimes(2);
expect(t.uniqueFontFamilies).not.toHaveBeenCalled();

await act(async () => vi.advanceTimersByTimeAsync(500));
expect(t.uniqueFontFamilies).toHaveBeenCalledTimes(1);
await act(async () => t.toggle());
expect(t.host.textContent).toContain("Roboto Slab");
} finally {
stopLoop();
t.done();
window.requestAnimationFrame = raf;
vi.useRealTimers();
}
});

it("retries a failed list fetch when the picker opens", async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
const t = await fresh();
try {
await act(async () => t.render("Georgia"));
await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
expect(t.uniqueFontFamilies).not.toHaveBeenCalled();

await act(async () => t.toggle());
await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
expect(fetch).toHaveBeenCalledTimes(4);
expect(t.host.textContent).toContain("Roboto Slab");
} finally {
t.done();
}
});
});
115 changes: 69 additions & 46 deletions packages/studio/src/components/editor/propertyPanelFont.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
googleFontStylesheetUrl,
POPULAR_GOOGLE_FONT_FAMILIES,
Expand All @@ -19,6 +19,7 @@ import {
type LocalFontData,
} from "./propertyPanelHelpers";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { runWhenInputIdle } from "./overlayFrameLoop";

/* ------------------------------------------------------------------ */
/* Font helper functions */
Expand Down Expand Up @@ -117,6 +118,62 @@ function loadImportedFontStylesheet(asset: ImportedFontAsset): void {
document.head.appendChild(style);
}

type FontLists = {
loaded: boolean;
google: readonly string[];
googleKeys: ReadonlySet<string>;
installed: string[];
};

let fontLists: FontLists = {
loaded: false,
google: POPULAR_GOOGLE_FONT_FAMILIES,
googleKeys: new Set(POPULAR_GOOGLE_FONT_FAMILIES.map((f) => f.toLowerCase())),
installed: [],
};
let fontListsRequest: Promise<void> | null = null;
const fontListListeners = new Set<() => void>();

async function fetchFontList(url: string): Promise<string[]> {
const data = (await (await fetch(url)).json()) as { fonts?: unknown };
if (!Array.isArray(data.fonts)) throw new Error(`${url} returned no font list`);
return data.fonts as string[];
}

function loadFontLists(): void {
if (fontLists.loaded) return;
fontListsRequest ??= Promise.all([
fetchFontList("/api/fonts"),
fetchFontList("/api/fonts/google"),
])
.then(([installed, google]) => {
const names = google.concat(POPULAR_GOOGLE_FONT_FAMILIES);
const families: string[] = [];
const googleKeys = new Set<string>();
let next = 0;
runWhenInputIdle((timeLeft) => {
while (next < names.length && timeLeft() > 0) {
const batch = names.slice(next, next + 200);
next += batch.length;
families.push(...uniqueFontFamilies(batch, googleKeys));
}
if (next < names.length) return false;
fontLists = { loaded: true, google: families, googleKeys, installed };
for (const listener of fontListListeners) listener();
return true;
});
})
.catch(() => {
fontListsRequest = null;
});
}

function subscribeFontLists(listener: () => void): () => void {
fontListListeners.add(listener);
loadFontLists();
return () => fontListListeners.delete(listener);
}

/* ------------------------------------------------------------------ */
/* FontFamilyField */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -146,9 +203,8 @@ export function FontFamilyField({
const [activeIndex, setActiveIndex] = useState(-1);
const [localFonts, setLocalFonts] = useState<string[]>([]);
const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
const lists = useSyncExternalStore(subscribeFontLists, () => fontLists);
const [loadingLocalFonts, setLoadingLocalFonts] = useState(false);
const [loadingGoogleFonts, setLoadingGoogleFonts] = useState(false);
const [importingFonts, setImportingFonts] = useState(false);
const [fontNotice, setFontNotice] = useState<string | null>(null);
const canQueryLocalFonts =
Expand All @@ -175,46 +231,18 @@ export function FontFamilyField({
}, [open]);

useEffect(() => {
let cancelled = false;
void fetch("/api/fonts")
.then((r) => (r.ok ? r.json() : null))
.then((data: { fonts?: string[] } | null) => {
if (cancelled || !Array.isArray(data?.fonts)) return;
setLocalFonts((cur) => uniqueFontFamilies([...cur, ...data.fonts!]));
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);

useEffect(() => {
let cancelled = false;
setLoadingGoogleFonts(true);
void fetch("/api/fonts/google")
.then((r) => (r.ok ? r.json() : null))
.then((data: { fonts?: string[] } | null) => {
if (cancelled || !Array.isArray(data?.fonts)) return;
setGoogleFonts(uniqueFontFamilies([...data.fonts!, ...POPULAR_GOOGLE_FONT_FAMILIES]));
})
.catch(() => undefined)
.finally(() => {
if (!cancelled) setLoadingGoogleFonts(false);
});
return () => {
cancelled = true;
};
}, []);
if (open) loadFontLists();
}, [open]);

useEffect(() => {
if (googleFonts.some((f) => f.toLowerCase() === currentFamily.toLowerCase())) {
if (lists.googleKeys.has(currentFamily.toLowerCase())) {
loadGoogleFontStylesheet(currentFamily);
}
const imported = importedFonts.find(
(f) => f.family.toLowerCase() === currentFamily.toLowerCase(),
);
if (imported) loadImportedFontStylesheet(imported);
}, [currentFamily, googleFonts, importedFonts]);
}, [currentFamily, lists, importedFonts]);

const loadBrowserLocalFonts = async () => {
if (!canQueryLocalFonts || !window.queryLocalFonts) {
Expand Down Expand Up @@ -278,24 +306,19 @@ export function FontFamilyField({
const options = useMemo(() => {
if (!open) return [];
const documentFonts = collectDocumentFontFamilies();
const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
const taggedLocal = localFonts.map(
(family): FontOption => ({
family,
source: googleSet.has(family.toLowerCase()) ? "Google" : "Local",
}),
);
return sortFontOptions(
uniqueFontOptions([
{ family: currentFamily, source: "Current" },
...documentFonts.map((f): FontOption => ({ family: f, source: "Document" })),
...projectFontAssets,
...googleFonts.map((f): FontOption => ({ family: f, source: "Google" })),
...taggedLocal,
...lists.google.map((f): FontOption => ({ family: f, source: "Google" })),
...[...lists.installed, ...localFonts].map(
(f): FontOption => ({ family: f, source: "Local" }),
),
...DEFAULT_FONT_FAMILIES.map((f): FontOption => ({ family: f, source: "System" })),
]),
);
}, [open, currentFamily, googleFonts, localFonts, projectFontAssets]);
}, [open, currentFamily, lists, localFonts, projectFontAssets]);

const filteredOptions = useMemo(() => {
const matches = options.filter((o) => fontMatchesQuery(o.family, query));
Expand Down Expand Up @@ -392,7 +415,7 @@ export function FontFamilyField({
type="text"
value={query}
disabled={disabled}
placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
placeholder={lists.loaded ? "Search fonts" : "Loading Google Fonts..."}
onChange={(e) => {
setQuery(e.target.value);
setActiveIndex(-1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,7 @@ export function localFontSortScore(font: LocalFontData): number {
return 3;
}

export function uniqueFontFamilies(values: string[]): string[] {
const seen = new Set<string>();
export function uniqueFontFamilies(values: string[], seen = new Set<string>()): string[] {
return values.reduce<string[]>((result, value) => {
const family = value.trim();
if (!family) return result;
Expand Down
Loading