Add font family preview in typography selector (#10411)

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
This commit is contained in:
Juan de la Cruz 2026-07-20 10:06:02 +02:00 committed by GitHub
parent 33d478b532
commit cddbd8e897
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 670 additions and 8 deletions

View File

@ -15,6 +15,7 @@
},
"scripts": {
"build:app:assets": "node ./scripts/build-app-assets.js",
"build:fonts-preview": "node ./scripts/build-fonts-preview.js",
"build:storybook": "pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build",
"build:storybook:assets": "node ./scripts/build-storybook-assets.js",
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",

View File

@ -15,6 +15,8 @@ import pLimit from "p-limit";
import ppt from "pretty-time";
import wpool from "workerpool";
import { buildFontsPreviewSprite } from "./build-fonts-preview.js";
function getCoreCount() {
return os.cpus().length;
}
@ -48,8 +50,9 @@ async function findFiles(basePath, predicate, options = {}) {
return files;
}
function syncDirs(originPath, destPath) {
const command = `rsync -ar --delete ${originPath} ${destPath}`;
function syncDirs(originPath, destPath, excludes = []) {
const excludeArgs = excludes.map((p) => `--exclude=${p}`).join(" ");
const command = `rsync -ar --delete ${excludeArgs} ${originPath} ${destPath}`;
return new Promise((resolve, reject) => {
proc.exec(command, (cause, stdout) => {
@ -540,6 +543,36 @@ export async function compileSvgSprites() {
}
}
export async function compileFontsPreviewSprite() {
const start = process.hrtime();
log.info("init: compile fonts preview sprite");
let error = false;
let result;
try {
result = await buildFontsPreviewSprite();
} catch (cause) {
error = cause;
}
const end = process.hrtime(start);
if (error) {
log.error("error: compile fonts preview sprite", `(${ppt(end)})`);
console.error(error);
} else if (result.skipped) {
log.info(
"done: compile fonts preview sprite (up-to-date, skipped)",
`(${ppt(end)})`,
);
} else {
log.info(
`done: compile fonts preview sprite (${result.ok} ok, ${result.failed} fallback)`,
`(${ppt(end)})`,
);
}
}
export async function compileTemplates() {
const start = process.hrtime();
let error = false;
@ -584,7 +617,11 @@ export async function copyAssets() {
log.info("init: copy assets");
await syncDirs("resources/images/", "resources/public/images/");
await syncDirs("resources/fonts/", "resources/public/fonts/");
// The font preview sprite is generated into public/fonts/ (not committed), so
// exclude it from --delete to keep it across builds (see compileFontsPreviewSprite).
await syncDirs("resources/fonts/", "resources/public/fonts/", [
"fonts-preview-sprite.svg",
]);
const end = process.hrtime(start);
log.info("done: copy assets", `(${ppt(end)})`);

View File

@ -3,6 +3,7 @@ import * as h from "./_helpers.js";
await h.ensureDirectories();
await h.compileStyles();
await h.copyAssets();
await h.compileFontsPreviewSprite();
await h.copyWasmPlayground();
await h.compileSvgSprites();
await h.compileTranslations();

View File

@ -0,0 +1,384 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
// Builds one SVG sprite previewing every catalog (built-in + Google) font name,
// outlined in its own typeface, so the picker loads it once instead of one
// request per font. Custom uploads aren't baked in and use the runtime fallback.
//
// Part of the asset build (compileFontsPreviewSprite in _helpers.js):
// regenerates only when missing or older than the gfonts catalog. Run directly
// to force a rebuild. See the technical-guide doc for the full design.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import ph from "node:path";
import url from "node:url";
import opentype from "opentype.js";
// Coordinates are emitted in CSS px (1 user unit == 1px), so the UI sizes rows
// without per-font metrics. Each name is laid out on its baseline then fitted
// into a fixed BOX_HEIGHT box (see buildSymbol). See the technical-guide doc.
const FONT_SIZE = 18; // visual cap/x-height target, matches the label typography
const BOX_HEIGHT = 28; // display box height in px; keep in sync with the scss
const VPAD = 1; // px of breathing room top/bottom before a name is scaled to fit
// Decimals of precision. 1 (≈0.1px grid) keeps glyphs smooth; 0 makes them
// wobbly. Sprite is ~2.3MB gzip at 1 with the relative encoding (serializePath).
const PATH_PRECISION = 1;
const CACHE_DIR = ph.join(os.tmpdir(), "penpot-fonts-preview-cache");
const CONCURRENCY = 24;
// Written straight to the served dir (like the SVG sprite), and excluded from
// copyAssets' `rsync --delete` so it survives builds — see the doc / _helpers.js.
export const OUTPUT = "resources/public/fonts/fonts-preview-sprite.svg";
// Color fonts (COLR/SVG glyph tables) make opentype.js take a browser-only
// DOMParser path that rejects a promise and crashes Node. We only need vector
// outlines, so swallow those rejections and the COLR warning during generation
// (restored afterwards so other build steps are unaffected).
function installFontParsingGuards() {
const onRejection = () => {};
process.on("unhandledRejection", onRejection);
const origWarn = console.warn;
console.warn = (msg, ...rest) => {
if (typeof msg === "string" && msg.includes("COLR")) return;
origWarn(msg, ...rest);
};
return () => {
process.off("unhandledRejection", onRejection);
console.warn = origWarn;
};
}
// Mirror of cuerdas.core/slug as used by the `parse-gfont` macro in
// src/app/main/fonts.clj so that ids match `(str "gfont-" (str/slug family))`.
function slug(value) {
return value
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
async function findGfontsJson() {
const dir = "resources/fonts";
const entries = await fs.readdir(dir);
const matches = entries.filter((f) => /^gfonts\..*\.json$/.test(f)).sort();
if (matches.length === 0) {
throw new Error(`no gfonts.*.json found in ${dir}`);
}
return ph.join(dir, matches[matches.length - 1]);
}
async function readCatalog() {
const builtin = [
{
id: "sourcesanspro",
name: "Source Sans Pro",
source: {
type: "file",
path: "resources/fonts/sourcesanspro-regular.ttf",
},
},
];
const gfontsPath = await findGfontsJson();
const raw = JSON.parse(await fs.readFile(gfontsPath, "utf-8"));
const google = (raw.items || []).map((item) => {
const family = item.family;
// Prefer the "menu" subset: a tiny TTF Google ships containing exactly the
// glyphs needed to render the family name in a font picker.
const url =
item.menu ||
(item.files && (item.files.regular || Object.values(item.files)[0]));
return {
id: `gfont-${slug(family)}`,
name: family,
source: { type: "url", url },
};
});
return [...builtin, ...google];
}
async function fetchWithCache(url) {
const key = createHash("sha1").update(url).digest("hex") + ".ttf";
const cached = ph.join(CACHE_DIR, key);
try {
return await fs.readFile(cached);
} catch {
// not cached yet
}
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
await fs.writeFile(cached, buf);
return buf;
} catch (err) {
lastErr = err;
}
}
throw lastErr;
}
async function loadFontBuffer(source) {
if (source.type === "file") return fs.readFile(source.path);
if (!source.url) throw new Error("missing font url");
return fetchWithCache(source.url);
}
// Lay the name out glyph-by-glyph with sanitized advances, NOT via
// `font.getPath`: some menu subsets have a broken kern/NaN advance that emits
// `NaN` into the path, which makes browsers stop parsing mid-path (only the
// first glyph renders). Baseline at y=0; buildSymbol re-centers afterwards.
// `hasTofu` flags glyphs the subset lacks (.notdef → "tofu" boxes), dropping the
// font to the runtime fallback.
function renderName(font, text) {
const scale = FONT_SIZE / font.unitsPerEm;
const path = new opentype.Path();
let x = 0;
let hasTofu = false;
for (const ch of text) {
const glyph = font.charToGlyph(ch);
if (glyph.index === 0 && ch.trim() !== "") hasTofu = true;
path.extend(glyph.getPath(x, 0, FONT_SIZE));
const advance = glyph.advanceWidth;
x += (Number.isFinite(advance) ? advance : font.unitsPerEm * 0.5) * scale;
}
return { path, hasTofu };
}
// Round to PATH_PRECISION decimals (normalizing -0). Done BEFORE deltas are
// taken (serializePath) so the relative encoding never drifts.
function roundGrid(value) {
return Number(value.toFixed(PATH_PRECISION)) + 0;
}
// Format an (already-rounded) number compactly: drop the integer "0" from
// "0.x"/"-0.x" to save bytes (".x"/"-.x" are valid SVG numbers).
function fmt(n) {
let s = n.toString();
if (s.startsWith("0.")) s = s.slice(1);
else if (s.startsWith("-0.")) s = "-" + s.slice(2);
return s;
}
// Serialize paths ourselves (opentype's `toPathData` separates numbers with
// spaces only — ambiguous enough that browsers bail mid-path). Format: comma
// WITHIN a pair, space BETWEEN pairs. Commands are RELATIVE (lowercase m/l/q/c)
// for much smaller, better-compressing output (~6.2MB → ~2.3MB gzip), with no
// geometry change. Curve control points are relative to the pen before the
// command, and `z` returns the pen to the subpath start — both tracked below.
// Coordinates are baked through the affine fit (scale `s`, translate tx/ty).
function serializePath(commands, s, tx, ty) {
const ax = (v) => roundGrid(v * s + tx);
const ay = (v) => roundGrid(v * s + ty);
let out = "";
// px/py: current pen (rounded, absolute). sx/sy: start of the current subpath,
// which the pen snaps back to on `z`.
let px = 0,
py = 0,
sx = 0,
sy = 0;
// Re-round the delta: subtracting two grid values reintroduces float noise
// (13.5 - 10.9 === 2.6000000000000014). Drift-free, since the pen tracks the
// exact rounded absolute (x/y), not the emitted delta.
const d = (to, from) => fmt(roundGrid(to - from));
for (const c of commands) {
switch (c.type) {
case "M": {
const x = ax(c.x),
y = ay(c.y);
out += `m${d(x, px)},${d(y, py)}`;
px = sx = x;
py = sy = y;
break;
}
case "L": {
const x = ax(c.x),
y = ay(c.y);
out += `l${d(x, px)},${d(y, py)}`;
px = x;
py = y;
break;
}
case "Q": {
const x1 = ax(c.x1),
y1 = ay(c.y1),
x = ax(c.x),
y = ay(c.y);
out += `q${d(x1, px)},${d(y1, py)} ${d(x, px)},${d(y, py)}`;
px = x;
py = y;
break;
}
case "C": {
const x1 = ax(c.x1),
y1 = ay(c.y1),
x2 = ax(c.x2),
y2 = ay(c.y2),
x = ax(c.x),
y = ay(c.y);
out +=
`c${d(x1, px)},${d(y1, py)} ` +
`${d(x2, px)},${d(y2, py)} ${d(x, px)},${d(y, py)}`;
px = x;
py = y;
break;
}
case "Z":
out += "z";
px = sx;
py = sy;
break;
}
}
return out;
}
function buildSymbol({ id, name }, buffer) {
const ab = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
);
const font = opentype.parse(ab);
// Drop the SVG color-glyph table to force outline rendering (see above).
font.tables.svg = undefined;
const { path, hasTofu } = renderName(font, name);
// Drop fonts whose name needs glyphs the subset lacks — they would render as
// .notdef boxes; the runtime fallback loads the real font instead.
if (hasTofu) throw new Error("missing glyphs (tofu)");
const bb = path.getBoundingBox();
if (!Number.isFinite(bb.x1) || bb.x2 <= bb.x1) throw new Error("empty path");
// Fit into BOX_HEIGHT, centered by bounding box so tall ascenders/descenders
// aren't clipped; names taller than the box are scaled down. Left-aligned at 0.
const usable = BOX_HEIGHT - 2 * VPAD;
const height = bb.y2 - bb.y1;
const s = height > usable ? usable / height : 1;
const tx = -bb.x1 * s;
const ty = BOX_HEIGHT / 2 - ((bb.y1 + bb.y2) / 2) * s;
const d = serializePath(path.commands, s, tx, ty);
if (!d || d.length === 0) throw new Error("empty path");
// A stray NaN (non-finite outline point) would truncate the glyph in the
// browser; drop the font so it cleanly falls back to the runtime loader.
if (d.includes("NaN")) throw new Error("non-finite path");
// No `fill`: the UI's <use> provides `currentColor` so it follows the theme.
return `<g id="font-preview-${id}"><path d="${d}"/></g>`;
}
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await fn(items[index], index);
}
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, worker),
);
return results;
}
// True when the sprite exists and is at least as new as the gfonts catalog, so
// the build can skip the expensive, network-bound regeneration.
export async function isSpriteUpToDate(outputPath = OUTPUT) {
try {
const gfontsPath = await findGfontsJson();
const [outStat, gfontsStat] = await Promise.all([
fs.stat(outputPath),
fs.stat(gfontsPath),
]);
return outStat.mtimeMs >= gfontsStat.mtimeMs;
} catch {
// output missing (or no catalog) → not up to date
return false;
}
}
// Regenerate the sprite into `outputPath`. Skips (returns `{ skipped: true }`)
// when the output is already up to date, unless `force` is set. On a real
// rebuild it returns `{ skipped:false, ok, failed, bytes }`.
export async function buildFontsPreviewSprite({
outputPath = OUTPUT,
force = false,
} = {}) {
if (!force && (await isSpriteUpToDate(outputPath))) {
return { skipped: true };
}
const restoreGuards = installFontParsingGuards();
try {
await fs.mkdir(CACHE_DIR, { recursive: true });
const catalog = await readCatalog();
console.log(`building font preview sprite for ${catalog.length} fonts…`);
let ok = 0;
const failed = [];
const symbols = await mapLimit(catalog, CONCURRENCY, async (font) => {
try {
const buffer = await loadFontBuffer(font.source);
const symbol = buildSymbol(font, buffer);
ok++;
if (ok % 200 === 0) console.log(`${ok}/${catalog.length}`);
return symbol;
} catch (err) {
failed.push({ id: font.id, reason: err.message });
return null;
}
});
const body = symbols.filter(Boolean).join("");
const sprite =
`<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">` +
body +
`</svg>\n`;
await fs.mkdir(ph.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, sprite);
if (failed.length) {
console.log(
`font preview sprite: ${failed.length} fonts will use the runtime fallback:`,
);
for (const f of failed.slice(0, 40))
console.log(` - ${f.id}: ${f.reason}`);
if (failed.length > 40) console.log(` …and ${failed.length - 40} more`);
}
return { skipped: false, ok, failed: failed.length, bytes: sprite.length };
} finally {
restoreGuards();
}
}
// When invoked directly (`node scripts/build-fonts-preview.js`), force a full
// rebuild regardless of the up-to-date check.
const isDirectRun =
process.argv[1] &&
import.meta.url === url.pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
const res = await buildFontsPreviewSprite({ force: true });
const mb = (res.bytes / 1024 / 1024).toFixed(1);
console.log(
`done: ${res.ok} ok, ${res.failed} failed → ${OUTPUT} (${mb} MB, served gzipped)`,
);
}

View File

@ -63,6 +63,7 @@ async function compileSass(path) {
await h.ensureDirectories();
await compileSassAll();
await h.copyAssets();
await h.compileFontsPreviewSprite();
await h.copyWasmPlayground();
await h.compileTranslations();
await h.compileSvgSprites();

View File

@ -196,6 +196,11 @@
(get :path)
(str "?version=" version-tag)))
(def fonts-preview-sprite-uri
(-> public-uri
(u/join "fonts/fonts-preview-sprite.svg")
(str)))
(defn external-feature-flag
[flag value]
(let [f (obj/get global "externalFeatureFlag")]

View File

@ -108,6 +108,108 @@
;; only know if the font is needed or not
(defonce ^:dynamic loaded-hints (l/atom #{}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PREVIEW SPRITE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; A prebuilt SVG sprite (generated by scripts/build-fonts-preview.js) holds every
;; built-in + Google font name outlined in its own typeface, so the picker can
;; preview the whole catalog with no per-font requests. Fonts not in it (custom
;; uploads, ones that fail to bake) use the runtime fallback.
;;
;; The sprite is heavy (~2000 nodes), so we DON'T keep it in the DOM: the fetched
;; markup is cached here as a string (`:svg`) and the nodes are materialized only
;; while the picker is open (attach/detach below). `:ids` are the font ids it
;; covers, so the UI can pick sprite vs fallback.
(defonce preview-sprite (l/atom {:status :idle :ids #{} :svg nil}))
;; Id prefix shared with the generator and the UI's `<use href>`; referenced here
;; rather than re-declared so the contract stays in one place.
(def preview-sprite-prefix "font-preview-")
(defn- collect-preview-ids
"Set of font ids present in the sprite, read from the injected `container`
element's `<g id=\"font-preview-…\">` groups (prefix stripped)."
[^js container]
(let [nodes (dom/query-all container (dm/str "g[id^=\"" preview-sprite-prefix "\"]"))
plen (count preview-sprite-prefix)]
(persistent!
(reduce (fn [acc node]
(conj! acc (subs (dom/get-attribute node "id") plen)))
(transient #{})
(array-seq nodes)))))
(defn- reset-preview-sprite-error!
[]
;; :error → the UI shows plain names (no previews, no per-font load storm); a
;; later `prefetch-preview-sprite!` call can retry.
(reset! preview-sprite {:status :error :ids #{} :svg nil}))
(defn- parse-sprite-svg
"Parse the cached sprite markup as SVG (not HTML, so no innerHTML injection
surface). Returns the root `<svg>` element, or nil if it isn't valid SVG."
[text]
(let [doc (.parseFromString (js/DOMParser.) text "image/svg+xml")
root (.-documentElement doc)]
;; A malformed document yields a <parsererror> root instead of <svg>.
(when (and (= "svg" (.-tagName ^js root))
(nil? (dom/query doc "parsererror")))
root)))
(defn prefetch-preview-sprite!
"Fetch the font-preview sprite markup and cache it in memory (no DOM yet — see
`attach-preview-sprite!`). Idempotent: fetches only when nothing is cached yet
(`:idle`) or a previous attempt failed (`:error`); no-op while `:loading` or
`:ready`."
[]
(when (and (globals/browser?)
(contains? #{:idle :error} (:status @preview-sprite)))
(swap! preview-sprite assoc :status :loading)
(->> (http/send! {:method :get
:uri cf/fonts-preview-sprite-uri
:response-type :text
:mode :cors})
(rx/subs!
(fn [response]
;; http/send! doesn't reject on non-2xx; guard so an error body isn't
;; cached as the sprite.
(if (http/success? response)
(swap! preview-sprite assoc :status :ready :svg (:body response))
(do
(log/wrn :hint "cannot load font preview sprite" :status (:status response))
(reset-preview-sprite-error!))))
(fn [cause]
(log/wrn :hint "cannot load font preview sprite" :cause cause)
(reset-preview-sprite-error!))))))
(defn attach-preview-sprite!
"Materialize the cached sprite into the DOM (hidden) so rows can reference its
glyph groups via `<use>`, and record the covered font ids. Returns the injected
node (pass it to `detach-preview-sprite!` on close), or nil if not ready / the
markup is invalid. Parsing happens here, not on prefetch, so the cost is paid
only while the picker is open."
[]
(let [{:keys [status svg]} @preview-sprite]
(when (and (globals/browser?) (= :ready status) (some? svg))
(if-let [node (some-> (parse-sprite-svg svg) (dom/import-node))]
;; The node already carries display:none + aria-hidden from the generator.
(do
(dom/set-attribute! node "id" "font-preview-sprite")
(when-let [body-el (unchecked-get globals/document "body")]
(dom/append-child! body-el node))
(swap! preview-sprite assoc :ids (collect-preview-ids node))
node)
(do
(log/wrn :hint "cannot parse font preview sprite")
(reset-preview-sprite-error!)
nil)))))
(defn detach-preview-sprite!
"Remove the sprite node injected by `attach-preview-sprite!` from the DOM. The
cached markup and `:ids` stay, so reopening re-attaches without a refetch."
[node]
(dom/remove! node))
(defn- add-font-css!
"Creates a style element and attaches it to the dom."
[id css]
@ -158,7 +260,7 @@
(defmethod load-font :google
[{:keys [id ::on-loaded] :as font}]
(when (exists? js/window)
(when (globals/browser?)
(log/dbg :hint "load-font" :font-id id :backend "google")
(let [url (generate-gfonts-url font)]
(->> (fetch-gfont-css url)
@ -200,7 +302,7 @@
(defmethod load-font :custom
[{:keys [id ::on-loaded] :as font}]
(when (exists? js/window)
(when (globals/browser?)
(log/dbg :hint "load-font" :font-id id :backend "custom")
(let [css (generate-custom-font-css font)]
(add-font-css! id css)
@ -215,7 +317,7 @@
([font-id] (ensure-loaded! font-id nil))
([font-id variant-id]
(log/dbg :action "try-ensure-loaded!" :font-id font-id :variant-id variant-id)
(if-not (exists? js/window)
(if-not (globals/browser?)
;; If we are in the worker environment, we just mark it as loaded
;; without really loading it.
(do

View File

@ -8,12 +8,14 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.helpers :as dsh]
[app.main.data.persistence :as dps]
[app.main.data.plugins :as dpl]
[app.main.data.workspace :as dw]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.refs :as refs]
[app.main.router :as-alias rt]
[app.main.store :as st]
@ -230,6 +232,13 @@
(st/emit! (dps/initialize-persistence)
(dpl/update-plugins-permissions-peek)))
;; FLAG :font-preview — prefetch the preview sprite markup on workspace mount
;; (kept in memory, not the DOM) so the typography selector renders previews on
;; open with no network wait. Remove the flag check to drop the feature.
(mf/with-effect []
(when (contains? cf/flags :font-preview)
(fonts/prefetch-preview-sprite!)))
;; Setting the layout preset by its name
(mf/with-effect [layout-name]
(st/emit! (dw/initialize-workspace-layout layout-name)))

View File

@ -12,6 +12,7 @@
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.types.text :as txt]
[app.config :as cf]
[app.main.constants :refer [max-input-length]]
[app.main.data.common :as dcm]
[app.main.data.fonts :as fts]
@ -39,6 +40,7 @@
[app.util.timers :as tm]
[cuerdas.core :as str]
[goog.events :as events]
[promesa.core :as p]
[rumext.v2 :as mf]))
(defn- attr->string [value]
@ -63,11 +65,73 @@
(or next (peek fonts)))
current))
(defn- use-font-lazy-load
"Lazily loads `font-id` for the fallback preview when `fallback?`, on idle so
fast scrolling over recycled virtualized rows doesn't storm requests (cancelled
if the row is reused first). Returns whether the font face is loaded yet."
[font-id fallback?]
(let [loaded? (mf/use-state #(and fallback? (contains? @fonts/loaded font-id)))]
(mf/use-effect
(mf/deps font-id fallback?)
(fn []
(let [already? (and fallback? (contains? @fonts/loaded font-id))]
(reset! loaded? already?)
(if (and fallback? (not already?))
(let [cancelled? (volatile! false)
task (tm/schedule-on-idle
(fn []
(-> (fonts/ensure-loaded! font-id)
(p/then (fn [_]
(when-not @cancelled?
(reset! loaded? true))))
(p/catch (fn [_] nil)))))]
(fn []
(vreset! cancelled? true)
(tm/dispose! task)))
(constantly nil)))))
@loaded?))
;; --- FEATURE: font preview (flag :font-preview) ------------------------------
;; font-item-preview* and use-font-lazy-load are the whole feature. They are only
;; rendered/called behind the `:font-preview` flag check in font-item* below, so
;; their hooks never run when the flag is off. To remove the flag, inline
;; font-item-preview* into font-item* and drop the plain-name branch.
(mf/defc font-item-preview*
"Row content with previews: a vector preview from the shared sprite for catalog
fonts, or the font's own name lazily loaded for custom fonts the sprite doesn't
cover."
{::mf/wrap [mf/memo]}
[{:keys [font]}]
(let [font-id (:id font)
sprite (mf/deref fonts/preview-sprite)
in-sprite? (contains? (:ids sprite) font-id)
;; Fallback is ONLY for custom fonts: ones the (ready) sprite doesn't
;; cover. If the sprite isn't ready (loading/error) we show the plain name
;; rather than runtime-loading the whole catalog.
fallback? (and (= :ready (:status sprite))
(not in-sprite?))
loaded? (use-font-lazy-load font-id fallback?)]
(if in-sprite?
;; `fill: currentColor` (scss) makes the sprite glyph follow the row color.
[:svg {:class (stl/css :font-item-preview)
:role "img"
:aria-label (:name font)}
[:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]]
[:span {:class (stl/css :font-item-label)
:style (when loaded?
#js {:fontFamily (dm/str "\"" (:family font) "\", sans-serif")})}
(:name font)])))
(mf/defc font-item*
{::mf/wrap [mf/memo]}
[{:keys [font is-current on-click style]}]
(let [item-ref (mf/use-ref)
on-click (mf/use-fn (mf/deps font) #(on-click font))]
on-click (mf/use-fn (mf/deps font) #(on-click font))
;; FLAG :font-preview — gates the feature markup AND its row styling
;; (.font-item-preview-on in the scss). Remove this and its two uses below.
preview? (contains? cf/flags :font-preview)]
(mf/use-effect
(mf/deps is-current)
@ -82,8 +146,11 @@
:ref item-ref
:on-click on-click}
[:div {:class (stl/css-case :font-item true
:font-item-preview-on preview?
:selected is-current)}
[:span {:class (stl/css :font-item-label)} (:name font)]
(if preview?
[:> font-item-preview* {:font font}]
[:span {:class (stl/css :font-item-label)} (:name font)])
(when is-current
[:> icon* {:icon-id i/tick
:size "s"}])]]))
@ -115,6 +182,8 @@
fonts (mf/with-memo [state fonts]
(filter-fonts state fonts))
sprite-status (:status (mf/deref fonts/preview-sprite))
recent-fonts (mf/deref refs/recent-fonts)
recent-fonts (mf/with-memo [state recent-fonts]
(filter-fonts state recent-fonts))
@ -165,6 +234,16 @@
(let [key (events/listen js/document "keydown" on-key-down)]
#(events/unlistenByKey key)))
;; FLAG :font-preview — materialize the preview sprite into the DOM only while
;; the picker is open (markup is prefetched on workspace load), removing it on
;; close so its ~2000 nodes aren't kept around idle. Remove the flag clause to
;; drop the feature.
(mf/with-effect [sprite-status]
(when (and (contains? cf/flags :font-preview)
(= :ready sprite-status))
(let [node (fonts/attach-preview-sprite!)]
#(fonts/detach-preview-sprite! node))))
(mf/with-effect [@selected]
(when-let [inst (mf/ref-val flist)]
(when-let [index (:index @selected)]

View File

@ -11,6 +11,10 @@
@use "ds/mixins.scss" as *;
@use "ds/_utils.scss" as *;
// Must match BOX_HEIGHT in scripts/build-fonts-preview.js (the sprite bakes each
// font name into that box at 1 unit == 1px).
$font-preview-box-height: 28px;
.typography-entry {
--actions-visibility: hidden;
--typography-entry-background-color: var(--color-background-tertiary);
@ -392,6 +396,31 @@
min-inline-size: 0;
}
// --- FLAG :font-preview row styling. Only applied when font-item* adds the
// .font-item-preview-on modifier; remove this whole block with the flag so rows
// render exactly as before.
.font-item-preview-on {
// Center & clip so a previewed font's own metrics never grow/overflow the row.
align-items: center;
overflow: hidden;
// Match the label size to the vector previews.
.font-item-label {
@include t.use-typography("body-medium");
line-height: 1.2;
}
}
// `currentColor` makes the glyph fill follow the row text color (theme + selected).
.font-item-preview {
flex-grow: 1;
min-inline-size: 0;
inline-size: 100%;
block-size: $font-preview-box-height;
fill: currentcolor;
}
.font-selector-dropdown-full-size {
block-size: var(--sidebar-element-options-height);
display: grid;

View File

@ -361,6 +361,13 @@
(.appendChild ^js el child))
el)
(defn import-node
"Import `node` (e.g. parsed in another document) into the current document so
it can be inserted. Deep clone unless `deep?` is false."
([^js node] (import-node node true))
([^js node deep?]
(.importNode globals/document node deep?)))
(defn insert-after!
[^js el ^js ref child]
(when (and (some? el) (some? ref))

View File

@ -22,6 +22,13 @@ goog.scope(function () {
self.global = globalThis;
// Whether we are running in a real browser (has a window), as opposed to a
// worker or the test environment, where the objects below are mocked.
// Exposed to ClojureScript as `globals/browser?`.
self.browser_QMARK_ = function () {
return typeof goog.global.window !== "undefined";
};
function createMockedEventEmitter(k) {
/* Allow mocked objects to be event emitters, so other modules
* may subscribe to them.