mirror of
https://github.com/penpot/penpot.git
synced 2026-07-22 05:57:56 +00:00
✨ Add font family preview in typography selector (#10411)
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
This commit is contained in:
parent
33d478b532
commit
cddbd8e897
@ -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",
|
||||
|
||||
@ -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)})`);
|
||||
|
||||
@ -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();
|
||||
|
||||
384
frontend/scripts/build-fonts-preview.js
Normal file
384
frontend/scripts/build-fonts-preview.js
Normal 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)`,
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user