mirror of
https://github.com/2234839/web-font.git
synced 2026-04-29 21:00:45 +08:00
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:
parent
878b54a0fd
commit
9da270733d
@ -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>
|
||||
|
||||
@ -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
103
public/webfont-sdk.js
Normal 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 };
|
||||
})();
|
||||
70
src/App.tsx
70
src/App.tsx
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user