389 lines · 14.1 KB · TypeScript
RawDownload
/** * Font preferences from Settings → Appearance, applied as CSS custom * properties. The default stacks mirror the `--font-sans` / `--font-mono` * definitions in `index.css`; a custom family is always prepended to the * matching default stack so glyph coverage never regresses. */ import {  DEFAULT_CODE_FONT_SIZE,  DEFAULT_INTERFACE_FONT_SIZE,  DEFAULT_PROMPT_FONT_SIZE,  MAX_CODE_FONT_SIZE,  MAX_INTERFACE_FONT_SIZE,  MAX_PROMPT_FONT_SIZE,  MIN_CODE_FONT_SIZE,  MIN_INTERFACE_FONT_SIZE,  MIN_PROMPT_FONT_SIZE,} from "@t3tools/contracts"; export const DEFAULT_SANS_FONT_STACK =  '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; // Concrete names first: some engines alias `ui-monospace` to the// proportional system UI font, which would break every code surface.export const DEFAULT_CODE_FONT_STACK =  '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced"; /** * Simple typography treats the terminal as another monospace surface. In * Advanced mode an empty terminal preference means the terminal default, * keeping later code-font changes isolated to code surfaces. */export function resolveTerminalFontPreference(input: {  readonly advanced: boolean;  readonly code: string;  readonly terminal: string;}): string {  if (input.advanced) return input.terminal;  return input.code;} export function resolveTerminalFontSizePreference(input: {  readonly advanced: boolean;  readonly code: number;  readonly terminal: number;}): number {  if (input.advanced) return input.terminal;  return input.code;} function quoteFontFamilyName(name: string): string {  const bare = name.trim();  if (bare.length === 0) return "";  // Already quoted, or a single ident that needs no quoting.  if (/^(['"]).*\1$/.test(bare)) return bare;  if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare;  return `"${bare.replaceAll('"', "")}"`;} /** * Normalize a user-entered family (single name or comma-separated list) into a * safe CSS font-family list, or null when the input is effectively empty. */export function cssFontFamilies(input: string): string | null {  const families = input    .split(",")    .map(quoteFontFamilyName)    .filter((name) => name.length > 0);  return families.length > 0 ? families.join(", ") : null;} export interface AppearanceFontPreferences {  readonly sans: string;  readonly code: string;  readonly composer: string;  readonly sizeInterface: number;  readonly sizePrompt: number;  readonly sizeCode: number;  /** Grayscale `antialiased` rendering; false keeps the heavier platform default. */  readonly smoothing: boolean;} /** * Apply the preferences to the root element. Unset families remove the * override so the stylesheet defaults (and theme changes) stay in charge. * * Sizes are always written: the interface size drives the root font size (and * with it every rem-based dimension), while the prompt and code sizes stay in * absolute pixels so they do not scale twice. */export function applyAppearanceFontVariables(  root: HTMLElement,  preferences: AppearanceFontPreferences,): void {  const families: ReadonlyArray<readonly [variable: string, custom: string, fallback: string]> = [    ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK],    ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK],    // The composer falls back to whatever the sans preference resolves to.    ["--font-composer", preferences.composer, "var(--font-sans)"],  ];  for (const [variable, custom, fallback] of families) {    const list = cssFontFamilies(custom);    if (list === null) {      root.style.removeProperty(variable);    } else {      root.style.setProperty(variable, `${list}, ${fallback}`);    }  }   root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`;  root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`);  const code = clampCodeFontSize(preferences.sizeCode);  root.style.setProperty("--font-size-code", `${code}px`);  // The @pierre/diffs surfaces read their own hook for code text.  root.style.setProperty("--diffs-font-size", `${code}px`);   // Inherited from the root; only macOS engines honor the property, so no  // platform gate is needed here. Smoothing on means grayscale `antialiased`  // (thinner strokes); off restores the platform default, which macOS renders  // with heavier stem darkening.  if (preferences.smoothing) {    root.style.setProperty("-webkit-font-smoothing", "antialiased");  } else {    root.style.removeProperty("-webkit-font-smoothing");  }} function clampFontSize(value: number, minimum: number, maximum: number, fallback: number): number {  if (!Number.isFinite(value)) return fallback;  return Math.min(maximum, Math.max(minimum, Math.round(value)));} export function clampInterfaceFontSize(value: number): number {  return clampFontSize(    value,    MIN_INTERFACE_FONT_SIZE,    MAX_INTERFACE_FONT_SIZE,    DEFAULT_INTERFACE_FONT_SIZE,  );} export function clampPromptFontSize(value: number): number {  return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE);} export function clampCodeFontSize(value: number): number {  return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE);} const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord";let fontProbeContext: CanvasRenderingContext2D | null | undefined; function probeWidth(fontList: string): number | null {  if (fontProbeContext === undefined) {    fontProbeContext = document.createElement("canvas").getContext("2d");  }  if (fontProbeContext === null) return null;  fontProbeContext.font = `16px ${fontList}`;  return fontProbeContext.measureText(FONT_PROBE_TEXT).width;} /** * Canvas metric probing instead of document.fonts.check(): check() reports * true for families that are not installed at all (nothing needs loading), so * it cannot filter the dropdown. A family exists when falling back to at * least one generic changes the measured advance. */export function isFontFamilyAvailable(family: string): boolean {  const families = cssFontFamilies(family);  if (families === null) return false;  if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true;  try {    for (const generic of ["monospace", "serif", "sans-serif"]) {      const baseline = probeWidth(generic);      const candidate = probeWidth(`${families}, ${generic}`);      if (baseline === null || candidate === null) return false;      if (candidate !== baseline) return true;    }    return false;  } catch {    return false;  }} const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const;const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const;const MONOSPACE_ADVANCE_TOLERANCE = 0.01; export function areFontAdvancesMonospace(advances: readonly number[]): boolean {  const reference = advances[0];  if (    reference === undefined ||    reference <= 0 ||    advances.some((advance) => !Number.isFinite(advance) || advance <= 0)  ) {    return true;  }  return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE);} /** * Whether a family renders every character on the same advance. Cell-grid * surfaces (the terminal) require this: a proportional face draws its text * narrower than the lattice the cursor and selection are placed on, which * reads as ragged gaps and a cursor stranded to the right of the text. * * Unmeasurable environments answer true, so a missing canvas never blocks a * legitimate font. */export function isMonospaceFamily(family: string): boolean {  const families = cssFontFamilies(family);  if (families === null) return true;  try {    if (fontProbeContext === undefined) {      fontProbeContext = document.createElement("canvas").getContext("2d");    }    if (fontProbeContext === null) return true;    const context = fontProbeContext;    // Fall back to a generic mono so an absent face measures as monospace and    // is left for the normal fallback chain to resolve.    for (const variant of MONOSPACE_PROBE_VARIANTS) {      context.font = `${variant} 32px ${families}, monospace`;      const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width);      if (!areFontAdvancesMonospace(advances)) return false;    }    return true;  } catch {    return true;  }} // Nameable faces the platform generics commonly map to, likeliest first.// Pixel-comparing a generic against these names the actual face; Apple's own// UI fonts are deliberately not CSS-nameable, so a miss on an Apple platform// identifies San Francisco itself.const SANS_GENERIC_CANDIDATES = [  "Segoe UI",  "Roboto",  "Noto Sans",  "Ubuntu",  "Cantarell",  "DejaVu Sans",  "Liberation Sans",  "Helvetica Neue",  "Arial",] as const;const MONO_GENERIC_CANDIDATES = [  "Menlo",  "Consolas",  "Cascadia Mono",  "DejaVu Sans Mono",  "Ubuntu Mono",  "Liberation Mono",  "Noto Sans Mono",  "Roboto Mono",  "Monaco",  "Courier New",] as const; const GENERIC_PROBE_TEXT = "RagIl10O@ fjord quiz"; /** * Advance width of the probe text laid out by the DOM - not canvas, whose * generic-family mapping diverges from real rendering (this engine draws * `ui-monospace` as the proportional UI font on canvas but not in CSS). * Identical widths at this size mean the same face for practical purposes. */function measureDomProbeWidth(fontFamily: string): number | null {  try {    const body = document.body;    if (!body) return null;    const span = document.createElement("span");    span.style.cssText =      "position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;font-size:100px;";    span.style.fontFamily = fontFamily;    span.textContent = GENERIC_PROBE_TEXT;    body.appendChild(span);    const width = span.getBoundingClientRect().width;    span.remove();    return width > 0 ? width : null;  } catch {    return null;  }} function widthsMatch(left: number, right: number): boolean {  return Math.abs(left - right) < 0.01;} /** * Name the concrete face a generic keyword renders as, by measuring the * generic against nameable candidates. Null when the face cannot be * identified (and the platform gives no definitional answer). */function resolveGenericFamilyLabel(generic: string): string | null {  const lower = generic.toLowerCase();  if (lower === "serif") return null;  const monoLike = lower === "ui-monospace" || lower === "monospace";  const genericWidth = measureDomProbeWidth(generic);  if (genericWidth === null) return null;  for (const candidate of monoLike ? MONO_GENERIC_CANDIDATES : SANS_GENERIC_CANDIDATES) {    if (!isFontFamilyAvailable(candidate)) continue;    const candidateWidth = measureDomProbeWidth(`"${candidate}"`);    if (candidateWidth !== null && widthsMatch(genericWidth, candidateWidth)) {      return candidate;    }  }  // No nameable face matched; on Apple platforms that means one of the San  // Francisco faces, which CSS cannot name. Comparing against -apple-system  // tells the UI face apart from SF Mono.  if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) {    const systemWidth = measureDomProbeWidth("-apple-system");    if (systemWidth !== null && widthsMatch(genericWidth, systemWidth)) return "SF Pro";    return monoLike ? "SF Mono" : "SF Pro";  }  return null;} /** * The first family of a default stack that will actually render - what the * "Default" choice means on this machine. Concrete names are probed for * availability; generic keywords are resolved to the face they draw with * where identifiable. Null when nothing can be named. */export function resolveDefaultFamilyLabel(stack: string): string | null {  for (const raw of stack.split(",")) {    const family = raw.trim().replace(/^(['"])(.*)\1$/, "$2");    if (family.length === 0) continue;    if (      /^(system-ui|sans-serif|serif|monospace|ui-monospace|-apple-system|BlinkMacSystemFont)$/i.test(        family,      )    ) {      const resolved = resolveGenericFamilyLabel(family);      if (resolved !== null) return resolved;      continue;    }    if (isFontFamilyAvailable(family)) return family;  }  return null;} export interface InstalledFontFamiliesResult {  readonly families: readonly string[];  /**   * "unsupported" - the engine has no Local Font Access API (Safari,   * Firefox); "denied" - the API exists but the user declined the permission   * prompt. Both fall back to the curated catalog.   */  readonly status: "granted" | "denied" | "unsupported";} let installedFamiliesCache: InstalledFontFamiliesResult | null = null; /** * Every installed family via the Local Font Access API (Chromium and * Electron). Call from a user gesture: the first call raises the browser's * local-fonts permission prompt. A denial is not cached, so reopening the * picker can ask again after the user changes the site setting. */export async function queryInstalledFontFamilies(): Promise<InstalledFontFamiliesResult> {  if (installedFamiliesCache !== null) return installedFamiliesCache;  const query = (    window as Window & {      queryLocalFonts?: () => Promise<ReadonlyArray<{ readonly family: string }>>;    }  ).queryLocalFonts;  if (typeof query !== "function") {    installedFamiliesCache = { families: [], status: "unsupported" };    return installedFamiliesCache;  }  try {    const fonts = await query.call(window);    const families = [...new Set(fonts.map((font) => font.family))]      // Dot-prefixed families are macOS-internal UI faces; selecting one is      // never intended and most refuse to render for web content anyway.      .filter((family) => !family.startsWith("."))      .sort((left, right) => left.localeCompare(right));    // A denied permission check resolves with an empty list instead of    // throwing; no machine has zero fonts, so treat empty as denied.    if (families.length === 0) return { families: [], status: "denied" };    installedFamiliesCache = { families, status: "granted" };    return installedFamiliesCache;  } catch {    return { families: [], status: "denied" };  }}