v1.2.0: 增量字体加载 SDK,优化预览体验

- 新增 webfont-sdk.js,支持按需增量加载字体片段,无闪烁
- 预览使用 SDK 增量加载,输入时不再重新加载已有字体
- SDK 根据元素类型(input/textarea vs 其他)正确获取文本
- SDK 支持重复调用同一选择器时自动清理旧状态
- 页面说明区分基础用法和进阶用法,提供 SDK 下载链接
- URL 改为完整域名(location.origin),方便用户直接复制使用
- 预览文本框字体大小调至 32px
- API Key 输入框改为 text 类型消除密码警告
- SEO 优化:添加 description meta,优化 title

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-04-08 20:48:05 +08:00
parent 878b54a0fd
commit 9da270733d
4 changed files with 140 additions and 39 deletions

View File

@ -4,10 +4,12 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Font</title>
<meta name="description" content="在线字体裁剪工具 — 服务端按需裁剪字体子集,大小无限制,免费开源。支持自定义裁剪、增量加载 SDK轻松嵌入任何网站。" />
<title>WebFont — 在线字体裁剪 | 按需加载 | 免费开源</title>
</head>
<body>
<div id="root"></div>
<script src="/webfont-sdk.js"></script>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@ -1,7 +1,7 @@
{
"name": "webfont",
"private": true,
"version": "1.1.0",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "pnpx tsx scripts/dev-all.ts",

103
public/webfont-sdk.js Normal file
View File

@ -0,0 +1,103 @@
/**
* WebFont SDK 按需增量加载字体片段无闪烁
* 用法WebFont.loadFont({ fontName, selector, baseUrl, family })
*/
var WebFont = (function () {
/** 按 selector 索引的活跃任务,重复调用同一选择器时自动清理旧任务 */
var tasks = {};
/**
* @param {Object} options
* @param {string} options.fontName - 字体文件名 "思源黑体.ttf"
* @param {string} options.selector - CSS 选择器 ".title"
* @param {string} [options.baseUrl] - API 基础地址默认当前域名
* @param {string} [options.family] - font-family 名称默认 fontName 去掉扩展名
*/
function loadFont(options) {
var selector = options.selector;
var fontName = options.fontName;
var baseUrl = options.baseUrl || location.origin;
var family = options.family || fontName.replace(/\.[^.]+$/, "");
/** 清理同一选择器的旧任务 */
if (tasks[selector]) {
clearInterval(tasks[selector].timer);
/** 移除旧任务注入的 style 标签 */
var oldStyles = tasks[selector].styles;
for (var s = 0; s < oldStyles.length; s++) {
oldStyles[s].remove();
}
}
var loadedChars = {};
var injectedStyles = [];
var applied = false;
/** 获取元素的文本内容 */
function getText(el) {
var tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
return el.value || "";
}
return el.textContent || "";
}
function collectChars() {
var charSet = {};
var elements = document.querySelectorAll(selector);
for (var i = 0; i < elements.length; i++) {
var text = getText(elements[i]);
for (var j = 0; j < text.length; j++) {
charSet[text[j]] = true;
}
}
return charSet;
}
function loadNewChars() {
var current = collectChars();
var newChars = [];
for (var c in current) {
if (!loadedChars[c]) {
newChars.push(c);
}
}
if (newChars.length === 0) return;
for (var k = 0; k < newChars.length; k++) {
loadedChars[newChars[k]] = true;
}
var text = newChars.join("");
var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text);
var unicodeRanges = newChars
.map(function (c) { return "U+" + c.codePointAt(0).toString(16).padStart(4, "0"); })
.join(", ");
var style = document.createElement("style");
style.textContent =
'@font-face {\n' +
' font-family: "' + family + '";\n' +
' src: url("' + url + '") format("truetype");\n' +
' unicode-range: ' + unicodeRanges + ';\n' +
'}\n';
document.head.appendChild(style);
injectedStyles.push(style);
if (!applied) {
applied = true;
var elements = document.querySelectorAll(selector);
for (var i = 0; i < elements.length; i++) {
elements[i].style.fontFamily = '"' + family + '", sans-serif';
}
}
}
loadNewChars();
var timer = setInterval(loadNewChars, 1000);
tasks[selector] = { timer: timer, styles: injectedStyles };
}
return { loadFont: loadFont };
})();

View File

@ -1,4 +1,4 @@
import { createMemo, createSignal, onMount, Show, For, type Accessor } from "solid-js";
import { createMemo, createSignal, onMount, Show, For } from "solid-js";
import { fetchFonts, fetchConfig, uploadFont, type FontInfo, type ServerConfig } from "./api";
const s = {
@ -39,7 +39,7 @@ const s = {
width: "100%",
height: "72px",
padding: "8px 12px",
"font-size": "18px",
"font-size": "32px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
resize: "vertical",
@ -110,7 +110,7 @@ function App() {
if (!font) return "";
return `@font-face {
font-family: "CustomFont";
src: url("/api?font=${font}&text=${encodeURIComponent(text())}") format("truetype");
src: url("${location.origin}/api?font=${font}&text=${encodeURIComponent(text())}") format("truetype");
}
.custom-font {
color: red;
@ -118,7 +118,22 @@ function App() {
}`;
});
const throttledCss = useThrottledMemo(() => cssStyle(), 1000, text);
/** 字体切换时使用 SDK 重新加载 */
const prevFontRef = { value: "" };
createMemo(() => {
const font = selectedFont();
if (!font) return;
if (font !== prevFontRef.value) {
prevFontRef.value = font;
const el = document.getElementById("webfont-preview");
if (el) el.style.fontFamily = 'inherit';
(globalThis as any).WebFont?.loadFont({
fontName: font,
selector: "#webfont-preview",
family: "CustomFont",
});
}
});
async function refreshFonts() {
const fontList = await fetchFonts();
@ -165,9 +180,9 @@ function App() {
<section style={s.section}>
<label style={s.label}></label>
<textarea
id="webfont-preview"
style={{
...s.textarea,
"font-family": selectedFont() ? '"CustomFont", sans-serif' : "inherit",
}}
value={text()}
onInput={(e) => set_text(e.target.value)}
@ -215,19 +230,27 @@ function App() {
<section style={{ ...s.section, "font-size": "12px", color: "#aaa", "line-height": "1.8" }}>
<p><b></b> text URL </p>
<p><b></b> CSS text </p>
<p><b></b> CSS text </p>
<pre style={{ ...s.pre, "font-size": "12px", "margin-top": "4px" }}>{`<style>
@font-face {
font-family: "MyFont";
src: url("https://your-domain/api?font=字体名&text=你的文字") format("truetype");
src: url("${location.origin}/api?font=字体名&text=你的文字") format("truetype");
}
.title { font-family: "MyFont"; }
</style>
<h1 class="title"></h1>`}</pre>
<p style={{ "margin-top": "12px" }}><b></b>使 JS SDK <a href="/webfont-sdk.js" download="webfont-sdk.js"> SDK</a></p>
<pre style={{ ...s.pre, "font-size": "12px", "margin-top": "4px" }}>{`<script src="${location.origin}/webfont-sdk.js"><\/script>
<script>
WebFont.loadFont({
fontName: "${selectedFont()}",
selector: ".title",
family: "MyFont",
});
<\/script>`}</pre>
</section>
<UploadSection config={serverConfig()} onUploaded={refreshFonts} />
<style>{throttledCss()}</style>
</div>
);
}
@ -336,7 +359,8 @@ function UploadSection(props: { config: ServerConfig; onUploaded: () => void })
API Key
</div>
<input
type="password"
type="text"
autocomplete="off"
style={{ ...s.input, width: "100%", "margin-bottom": "10px" }}
value={adminApiKey()}
onInput={(e) => set_adminApiKey(e.target.value)}
@ -370,32 +394,4 @@ function UploadSection(props: { config: ServerConfig; onUploaded: () => void })
);
}
function useThrottledMemo<T>(fn: () => T, delay: number, trigger?: Accessor<unknown>): Accessor<T> {
const [throttledValue, setThrottledValue] = createSignal<T>(fn());
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let isFirst = true;
createMemo(() => {
if (trigger) trigger();
const value = fn();
if (isFirst) {
isFirst = false;
// @ts-expect-error
setThrottledValue(value);
return;
}
if (timeoutId === null) {
// @ts-expect-error
setThrottledValue(value);
timeoutId = setTimeout(() => {
timeoutId = null;
// @ts-expect-error
setThrottledValue(fn());
}, delay);
}
});
return throttledValue;
}
export default App;