mirror of
https://github.com/penpot/penpot.git
synced 2026-08-01 02:46:20 +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": {
|
"scripts": {
|
||||||
"build:app:assets": "node ./scripts/build-app-assets.js",
|
"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": "pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build",
|
||||||
"build:storybook:assets": "node ./scripts/build-storybook-assets.js",
|
"build:storybook:assets": "node ./scripts/build-storybook-assets.js",
|
||||||
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",
|
"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 ppt from "pretty-time";
|
||||||
import wpool from "workerpool";
|
import wpool from "workerpool";
|
||||||
|
|
||||||
|
import { buildFontsPreviewSprite } from "./build-fonts-preview.js";
|
||||||
|
|
||||||
function getCoreCount() {
|
function getCoreCount() {
|
||||||
return os.cpus().length;
|
return os.cpus().length;
|
||||||
}
|
}
|
||||||
@ -48,8 +50,9 @@ async function findFiles(basePath, predicate, options = {}) {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncDirs(originPath, destPath) {
|
function syncDirs(originPath, destPath, excludes = []) {
|
||||||
const command = `rsync -ar --delete ${originPath} ${destPath}`;
|
const excludeArgs = excludes.map((p) => `--exclude=${p}`).join(" ");
|
||||||
|
const command = `rsync -ar --delete ${excludeArgs} ${originPath} ${destPath}`;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
proc.exec(command, (cause, stdout) => {
|
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() {
|
export async function compileTemplates() {
|
||||||
const start = process.hrtime();
|
const start = process.hrtime();
|
||||||
let error = false;
|
let error = false;
|
||||||
@ -584,7 +617,11 @@ export async function copyAssets() {
|
|||||||
log.info("init: copy assets");
|
log.info("init: copy assets");
|
||||||
|
|
||||||
await syncDirs("resources/images/", "resources/public/images/");
|
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);
|
const end = process.hrtime(start);
|
||||||
log.info("done: copy assets", `(${ppt(end)})`);
|
log.info("done: copy assets", `(${ppt(end)})`);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import * as h from "./_helpers.js";
|
|||||||
await h.ensureDirectories();
|
await h.ensureDirectories();
|
||||||
await h.compileStyles();
|
await h.compileStyles();
|
||||||
await h.copyAssets();
|
await h.copyAssets();
|
||||||
|
await h.compileFontsPreviewSprite();
|
||||||
await h.copyWasmPlayground();
|
await h.copyWasmPlayground();
|
||||||
await h.compileSvgSprites();
|
await h.compileSvgSprites();
|
||||||
await h.compileTranslations();
|
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 h.ensureDirectories();
|
||||||
await compileSassAll();
|
await compileSassAll();
|
||||||
await h.copyAssets();
|
await h.copyAssets();
|
||||||
|
await h.compileFontsPreviewSprite();
|
||||||
await h.copyWasmPlayground();
|
await h.copyWasmPlayground();
|
||||||
await h.compileTranslations();
|
await h.compileTranslations();
|
||||||
await h.compileSvgSprites();
|
await h.compileSvgSprites();
|
||||||
|
|||||||
@ -196,6 +196,11 @@
|
|||||||
(get :path)
|
(get :path)
|
||||||
(str "?version=" version-tag)))
|
(str "?version=" version-tag)))
|
||||||
|
|
||||||
|
(def fonts-preview-sprite-uri
|
||||||
|
(-> public-uri
|
||||||
|
(u/join "fonts/fonts-preview-sprite.svg")
|
||||||
|
(str)))
|
||||||
|
|
||||||
(defn external-feature-flag
|
(defn external-feature-flag
|
||||||
[flag value]
|
[flag value]
|
||||||
(let [f (obj/get global "externalFeatureFlag")]
|
(let [f (obj/get global "externalFeatureFlag")]
|
||||||
|
|||||||
@ -108,6 +108,108 @@
|
|||||||
;; only know if the font is needed or not
|
;; only know if the font is needed or not
|
||||||
(defonce ^:dynamic loaded-hints (l/atom #{}))
|
(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!
|
(defn- add-font-css!
|
||||||
"Creates a style element and attaches it to the dom."
|
"Creates a style element and attaches it to the dom."
|
||||||
[id css]
|
[id css]
|
||||||
@ -158,7 +260,7 @@
|
|||||||
|
|
||||||
(defmethod load-font :google
|
(defmethod load-font :google
|
||||||
[{:keys [id ::on-loaded] :as font}]
|
[{:keys [id ::on-loaded] :as font}]
|
||||||
(when (exists? js/window)
|
(when (globals/browser?)
|
||||||
(log/dbg :hint "load-font" :font-id id :backend "google")
|
(log/dbg :hint "load-font" :font-id id :backend "google")
|
||||||
(let [url (generate-gfonts-url font)]
|
(let [url (generate-gfonts-url font)]
|
||||||
(->> (fetch-gfont-css url)
|
(->> (fetch-gfont-css url)
|
||||||
@ -200,7 +302,7 @@
|
|||||||
|
|
||||||
(defmethod load-font :custom
|
(defmethod load-font :custom
|
||||||
[{:keys [id ::on-loaded] :as font}]
|
[{:keys [id ::on-loaded] :as font}]
|
||||||
(when (exists? js/window)
|
(when (globals/browser?)
|
||||||
(log/dbg :hint "load-font" :font-id id :backend "custom")
|
(log/dbg :hint "load-font" :font-id id :backend "custom")
|
||||||
(let [css (generate-custom-font-css font)]
|
(let [css (generate-custom-font-css font)]
|
||||||
(add-font-css! id css)
|
(add-font-css! id css)
|
||||||
@ -215,7 +317,7 @@
|
|||||||
([font-id] (ensure-loaded! font-id nil))
|
([font-id] (ensure-loaded! font-id nil))
|
||||||
([font-id variant-id]
|
([font-id variant-id]
|
||||||
(log/dbg :action "try-ensure-loaded!" :font-id font-id :variant-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
|
;; If we are in the worker environment, we just mark it as loaded
|
||||||
;; without really loading it.
|
;; without really loading it.
|
||||||
(do
|
(do
|
||||||
|
|||||||
@ -8,12 +8,14 @@
|
|||||||
(:require-macros [app.main.style :as stl])
|
(:require-macros [app.main.style :as stl])
|
||||||
(:require
|
(:require
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
|
[app.config :as cf]
|
||||||
[app.main.data.common :as dcm]
|
[app.main.data.common :as dcm]
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
[app.main.data.persistence :as dps]
|
[app.main.data.persistence :as dps]
|
||||||
[app.main.data.plugins :as dpl]
|
[app.main.data.plugins :as dpl]
|
||||||
[app.main.data.workspace :as dw]
|
[app.main.data.workspace :as dw]
|
||||||
[app.main.features :as features]
|
[app.main.features :as features]
|
||||||
|
[app.main.fonts :as fonts]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
[app.main.router :as-alias rt]
|
[app.main.router :as-alias rt]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
@ -230,6 +232,13 @@
|
|||||||
(st/emit! (dps/initialize-persistence)
|
(st/emit! (dps/initialize-persistence)
|
||||||
(dpl/update-plugins-permissions-peek)))
|
(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
|
;; Setting the layout preset by its name
|
||||||
(mf/with-effect [layout-name]
|
(mf/with-effect [layout-name]
|
||||||
(st/emit! (dw/initialize-workspace-layout layout-name)))
|
(st/emit! (dw/initialize-workspace-layout layout-name)))
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.types.text :as txt]
|
[app.common.types.text :as txt]
|
||||||
|
[app.config :as cf]
|
||||||
[app.main.constants :refer [max-input-length]]
|
[app.main.constants :refer [max-input-length]]
|
||||||
[app.main.data.common :as dcm]
|
[app.main.data.common :as dcm]
|
||||||
[app.main.data.fonts :as fts]
|
[app.main.data.fonts :as fts]
|
||||||
@ -39,6 +40,7 @@
|
|||||||
[app.util.timers :as tm]
|
[app.util.timers :as tm]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[goog.events :as events]
|
[goog.events :as events]
|
||||||
|
[promesa.core :as p]
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
(defn- attr->string [value]
|
(defn- attr->string [value]
|
||||||
@ -63,11 +65,73 @@
|
|||||||
(or next (peek fonts)))
|
(or next (peek fonts)))
|
||||||
current))
|
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/defc font-item*
|
||||||
{::mf/wrap [mf/memo]}
|
{::mf/wrap [mf/memo]}
|
||||||
[{:keys [font is-current on-click style]}]
|
[{:keys [font is-current on-click style]}]
|
||||||
(let [item-ref (mf/use-ref)
|
(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/use-effect
|
||||||
(mf/deps is-current)
|
(mf/deps is-current)
|
||||||
@ -82,8 +146,11 @@
|
|||||||
:ref item-ref
|
:ref item-ref
|
||||||
:on-click on-click}
|
:on-click on-click}
|
||||||
[:div {:class (stl/css-case :font-item true
|
[:div {:class (stl/css-case :font-item true
|
||||||
|
:font-item-preview-on preview?
|
||||||
:selected is-current)}
|
: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
|
(when is-current
|
||||||
[:> icon* {:icon-id i/tick
|
[:> icon* {:icon-id i/tick
|
||||||
:size "s"}])]]))
|
:size "s"}])]]))
|
||||||
@ -115,6 +182,8 @@
|
|||||||
fonts (mf/with-memo [state fonts]
|
fonts (mf/with-memo [state fonts]
|
||||||
(filter-fonts state fonts))
|
(filter-fonts state fonts))
|
||||||
|
|
||||||
|
sprite-status (:status (mf/deref fonts/preview-sprite))
|
||||||
|
|
||||||
recent-fonts (mf/deref refs/recent-fonts)
|
recent-fonts (mf/deref refs/recent-fonts)
|
||||||
recent-fonts (mf/with-memo [state recent-fonts]
|
recent-fonts (mf/with-memo [state recent-fonts]
|
||||||
(filter-fonts state recent-fonts))
|
(filter-fonts state recent-fonts))
|
||||||
@ -165,6 +234,16 @@
|
|||||||
(let [key (events/listen js/document "keydown" on-key-down)]
|
(let [key (events/listen js/document "keydown" on-key-down)]
|
||||||
#(events/unlistenByKey key)))
|
#(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]
|
(mf/with-effect [@selected]
|
||||||
(when-let [inst (mf/ref-val flist)]
|
(when-let [inst (mf/ref-val flist)]
|
||||||
(when-let [index (:index @selected)]
|
(when-let [index (:index @selected)]
|
||||||
|
|||||||
@ -11,6 +11,10 @@
|
|||||||
@use "ds/mixins.scss" as *;
|
@use "ds/mixins.scss" as *;
|
||||||
@use "ds/_utils.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 {
|
.typography-entry {
|
||||||
--actions-visibility: hidden;
|
--actions-visibility: hidden;
|
||||||
--typography-entry-background-color: var(--color-background-tertiary);
|
--typography-entry-background-color: var(--color-background-tertiary);
|
||||||
@ -392,6 +396,31 @@
|
|||||||
min-inline-size: 0;
|
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 {
|
.font-selector-dropdown-full-size {
|
||||||
block-size: var(--sidebar-element-options-height);
|
block-size: var(--sidebar-element-options-height);
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@ -361,6 +361,13 @@
|
|||||||
(.appendChild ^js el child))
|
(.appendChild ^js el child))
|
||||||
el)
|
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!
|
(defn insert-after!
|
||||||
[^js el ^js ref child]
|
[^js el ^js ref child]
|
||||||
(when (and (some? el) (some? ref))
|
(when (and (some? el) (some? ref))
|
||||||
|
|||||||
@ -22,6 +22,13 @@ goog.scope(function () {
|
|||||||
|
|
||||||
self.global = globalThis;
|
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) {
|
function createMockedEventEmitter(k) {
|
||||||
/* Allow mocked objects to be event emitters, so other modules
|
/* Allow mocked objects to be event emitters, so other modules
|
||||||
* may subscribe to them.
|
* may subscribe to them.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user