From cddbd8e8975c6e7e3546f511e66451cd0c3bf1eb Mon Sep 17 00:00:00 2001 From: Juan de la Cruz Date: Mon, 20 Jul 2026 10:06:02 +0200 Subject: [PATCH] :sparkles: Add font family preview in typography selector (#10411) Co-authored-by: alonso.torres --- frontend/package.json | 1 + frontend/scripts/_helpers.js | 43 +- frontend/scripts/build-app-assets.js | 1 + frontend/scripts/build-fonts-preview.js | 384 ++++++++++++++++++ frontend/scripts/watch.js | 1 + frontend/src/app/config.cljs | 5 + frontend/src/app/main/fonts.cljs | 108 ++++- frontend/src/app/main/ui/workspace.cljs | 9 + .../sidebar/options/menus/typography.cljs | 83 +++- .../sidebar/options/menus/typography.scss | 29 ++ frontend/src/app/util/dom.cljs | 7 + frontend/src/app/util/globals.js | 7 + 12 files changed, 670 insertions(+), 8 deletions(-) create mode 100644 frontend/scripts/build-fonts-preview.js diff --git a/frontend/package.json b/frontend/package.json index 32da31fbf9..f743baa28a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/scripts/_helpers.js b/frontend/scripts/_helpers.js index c6396f99bc..ea5a4dff0b 100644 --- a/frontend/scripts/_helpers.js +++ b/frontend/scripts/_helpers.js @@ -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)})`); diff --git a/frontend/scripts/build-app-assets.js b/frontend/scripts/build-app-assets.js index e0fe385dce..f1f5a970c8 100644 --- a/frontend/scripts/build-app-assets.js +++ b/frontend/scripts/build-app-assets.js @@ -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(); diff --git a/frontend/scripts/build-fonts-preview.js b/frontend/scripts/build-fonts-preview.js new file mode 100644 index 0000000000..b1aa7c839c --- /dev/null +++ b/frontend/scripts/build-fonts-preview.js @@ -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 provides `currentColor` so it follows the theme. + return ``; +} + +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 = + `\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)`, + ); +} diff --git a/frontend/scripts/watch.js b/frontend/scripts/watch.js index 38007d4454..700874be74 100644 --- a/frontend/scripts/watch.js +++ b/frontend/scripts/watch.js @@ -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(); diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 359a984097..dc2c5a237a 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -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")] diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 02181c3db8..761e1dbc6f 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -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 ``; 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 `` 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 `` 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 root instead of . + (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 ``, 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 diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index bb69485774..e70cbe685c 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -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))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index dc242cb19c..0f6cc60077 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -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)] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index b12d3cd835..4281c18e61 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -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; diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index faf5501f37..ca2809cfba 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -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)) diff --git a/frontend/src/app/util/globals.js b/frontend/src/app/util/globals.js index cd9d990023..2539c7a852 100644 --- a/frontend/src/app/util/globals.js +++ b/frontend/src/app/util/globals.js @@ -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.