diff --git a/package.json b/package.json index f3a1cc5..ae5de8b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "webfont", "private": true, - "version": "1.2.1", + "version": "1.3.0", "type": "module", "scripts": { "dev": "pnpx tsx scripts/dev-all.ts", diff --git a/public/webfont-sdk.js b/public/webfont-sdk.js index 93b5a89..093b17d 100644 --- a/public/webfont-sdk.js +++ b/public/webfont-sdk.js @@ -1,103 +1,378 @@ /** * WebFont SDK — 按需增量加载字体片段,无闪烁 - * 用法:WebFont.loadFont({ fontName, selector, baseUrl, family }) + * + * 架构:核心增量引擎 + 多种触发方式 + * - 核心:FontLoader 按 fontKey 管理已加载字符集,只生成增量 CSS + * - 触发器:loadFont(轮询)、observeFont(DOM 事件)、loadText(手动传文本) + * - 同一 fontKey 下所有触发器共享字符集,绝不会重复请求 + * + * 用法: + * // 轮询模式 + * WebFont.loadFont({ fontName, selector, family, interval }); + * + * // 事件驱动模式 + * var obs = WebFont.observeFont({ fontName, selector, family }); + * obs.dispose(); + * + * // 直接传文本模式 + * var loader = WebFont.loadText({ fontName, text: "你好世界", family }); + * loader.update("追加文字"); + * loader.dispose(); + * + * // 清理全部 + * WebFont.disposeAll(); */ var WebFont = (function () { - /** 按 selector 索引的活跃任务,重复调用同一选择器时自动清理旧任务 */ - var tasks = {}; + /* ============================================================ + * 核心增量引擎 — 按 fontKey 管理已加载字符集,生成增量 CSS + * ============================================================ */ + + /** @type {Object., injectedStyles: Element[], applied: boolean, fontName: string, family: string, baseUrl: string }>} */ + var loaders = {}; + + /** + * 生成 fontKey,同一字体+family 归入同一组 + */ + function fontKey(fontName, family) { + return fontName + "|" + family; + } + + /** + * 获取或创建对应 fontKey 的加载器 + */ + function getLoader(fontName, baseUrl, family) { + var key = fontKey(fontName, family); + if (!loaders[key]) { + loaders[key] = { + loadedChars: {}, + injectedStyles: [], + applied: false, + fontName: fontName, + family: family, + baseUrl: baseUrl + }; + } + return loaders[key]; + } + + /** + * 差量加载新字符,生成 unicode-range CSS 并注入 + * @param {Object} loader - getLoader 返回的加载器对象 + * @param {string[]} newChars - 待加载的新字符数组 + */ + function loadChars(loader, newChars) { + if (newChars.length === 0) return; + + var fontName = loader.fontName; + var family = loader.family; + var baseUrl = loader.baseUrl; + var loadedChars = loader.loadedChars; + + 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); + loader.injectedStyles.push(style); + } + + /** + * 从字符集中过滤出未加载的新字符,标记为已加载,并生成 CSS + * @param {Object} loader - getLoader 返回的加载器对象 + * @param {Object.} charSet - 待检查的字符集 + * @returns {boolean} 是否有新字符被加载 + */ + function processChars(loader, charSet) { + var loadedChars = loader.loadedChars; + var newChars = []; + for (var c in charSet) { + if (!loadedChars[c]) { + loadedChars[c] = true; + newChars.push(c); + } + } + loadChars(loader, newChars); + return newChars.length > 0; + } + + /** + * 从字符串中过滤出未加载的新字符,标记为已加载,并生成 CSS + * @param {Object} loader - getLoader 返回的加载器对象 + * @param {string} text - 待检查的文本 + * @returns {boolean} 是否有新字符被加载 + */ + function processText(loader, text) { + var loadedChars = loader.loadedChars; + var newChars = []; + for (var i = 0; i < text.length; i++) { + var c = text[i]; + if (!loadedChars[c]) { + loadedChars[c] = true; + newChars.push(c); + } + } + loadChars(loader, newChars); + return newChars.length > 0; + } + + /** + * 销毁加载器及其所有注入的样式 + */ + function destroyLoader(key) { + var loader = loaders[key]; + if (!loader) return; + for (var i = 0; i < loader.injectedStyles.length; i++) { + loader.injectedStyles[i].remove(); + } + delete loaders[key]; + } + + /* ============================================================ + * 辅助函数 + * ============================================================ */ + + /** + * 获取元素的文本内容 + */ + function getText(el) { + var tag = el.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") { + return el.value || ""; + } + return el.textContent || ""; + } + + /** + * 收集选择器匹配元素中的所有字符 + */ + function collectChars(selector) { + 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 applyFamily(selector, family) { + var elements = document.querySelectorAll(selector); + for (var i = 0; i < elements.length; i++) { + elements[i].style.fontFamily = '"' + family + '", sans-serif'; + } + } + + /* ============================================================ + * 任务管理 — 各触发器的清理 + * ============================================================ */ + + /** 按 selector 索引的 loadFont 任务 */ + var pollTasks = {}; + + /** 按选择器索引的 observeFont 任务 */ + var observeTasks = {}; + + /* ============================================================ + * 1. loadFont — 定时器轮询模式 + * ============================================================ */ /** * @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 去掉扩展名 + * @param {string} options.fontName + * @param {string} options.selector + * @param {string} [options.baseUrl] + * @param {string} [options.family] + * @param {number} [options.interval=1000] - 轮询间隔(ms) */ function loadFont(options) { var selector = options.selector; var fontName = options.fontName; var baseUrl = options.baseUrl || location.origin; var family = options.family || fontName.replace(/\.[^.]+$/, ""); + var interval = options.interval || 1000; - /** 清理同一选择器的旧任务 */ - if (tasks[selector]) { - clearInterval(tasks[selector].timer); - /** 移除旧任务注入的 style 标签 */ - var oldStyles = tasks[selector].styles; - for (var s = 0; s < oldStyles.length; s++) { - oldStyles[s].remove(); - } + /* 清理同一选择器的旧任务 */ + if (pollTasks[selector]) { + clearInterval(pollTasks[selector].timer); } - var loadedChars = {}; - var injectedStyles = []; + var loader = getLoader(fontName, baseUrl, family); 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) { + function tick() { + var current = collectChars(selector); + if (processChars(loader, current) && !applied) { applied = true; - var elements = document.querySelectorAll(selector); - for (var i = 0; i < elements.length; i++) { - elements[i].style.fontFamily = '"' + family + '", sans-serif'; - } + applyFamily(selector, family); } } - loadNewChars(); - var timer = setInterval(loadNewChars, 1000); - - tasks[selector] = { timer: timer, styles: injectedStyles }; + tick(); + var timer = setInterval(tick, interval); + pollTasks[selector] = { timer: timer }; } - return { loadFont: loadFont }; + /* ============================================================ + * 2. observeFont — MutationObserver 事件驱动模式 + * ============================================================ */ + + /** + * @param {Object} options + * @param {string} options.fontName + * @param {string} options.selector + * @param {string} [options.baseUrl] + * @param {string} [options.family] + * @param {number} [options.debounceMs=50] - 防抖间隔(ms) + * @returns {{ dispose: function }} + */ + function observeFont(options) { + var selector = options.selector; + var fontName = options.fontName; + var baseUrl = options.baseUrl || location.origin; + var family = options.family || fontName.replace(/\.[^.]+$/, ""); + var debounceMs = options.debounceMs || 50; + + /* 清理同一选择器的旧任务 */ + if (observeTasks[selector]) { + observeTasks[selector].dispose(); + } + + var loader = getLoader(fontName, baseUrl, family); + var applied = false; + var debounceTimer = null; + + function doLoad() { + var current = collectChars(selector); + if (processChars(loader, current) && !applied) { + applied = true; + applyFamily(selector, family); + } + } + + function debouncedLoad() { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(doLoad, debounceMs); + } + + var observer = new MutationObserver(function (mutations) { + for (var i = 0; i < mutations.length; i++) { + if (mutations[i].type === "childList" || mutations[i].type === "characterData") { + debouncedLoad(); + return; + } + } + }); + + var inputHandler = function () { debouncedLoad(); }; + + var elements = document.querySelectorAll(selector); + observer.observe(document.body || document.documentElement, { + childList: true, + subtree: true, + characterData: true, + }); + for (var i = 0; i < elements.length; i++) { + var el = elements[i]; + if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { + el.addEventListener("input", inputHandler); + } + } + + doLoad(); + + var disposed = false; + + var task = { + dispose: function () { + if (disposed) return; + disposed = true; + observer.disconnect(); + for (var j = 0; j < elements.length; j++) { + var el2 = elements[j]; + if (el2.tagName === "INPUT" || el2.tagName === "TEXTAREA") { + el2.removeEventListener("input", inputHandler); + } + } + if (debounceTimer) clearTimeout(debounceTimer); + delete observeTasks[selector]; + } + }; + + observeTasks[selector] = task; + return task; + } + + /* ============================================================ + * 3. loadText — 直接传文本模式 + * ============================================================ */ + + /** + * @param {Object} options + * @param {string} options.fontName + * @param {string} options.text + * @param {string} [options.baseUrl] + * @param {string} [options.family] + * @returns {{ update: function(string): void, dispose: function(): void }} + */ + function loadText(options) { + var fontName = options.fontName; + var baseUrl = options.baseUrl || location.origin; + var family = options.family || fontName.replace(/\.[^.]+$/, ""); + + var loader = getLoader(fontName, baseUrl, family); + + processText(loader, options.text); + + return { + update: function (text) { + processText(loader, text); + }, + dispose: function () { + /* loadText 不独占样式,样式由 loader 统一管理 */ + } + }; + } + + /* ============================================================ + * 公共 API + * ============================================================ */ + + /** + * 清理所有任务和加载器(页面卸载时调用) + */ + function disposeAll() { + for (var sel in pollTasks) { + clearInterval(pollTasks[sel].timer); + } + for (var oid in observeTasks) { + observeTasks[oid].dispose(); + } + pollTasks = {}; + observeTasks = {}; + + for (var key in loaders) { + destroyLoader(key); + } + } + + return { + loadFont: loadFont, + observeFont: observeFont, + loadText: loadText, + disposeAll: disposeAll + }; })(); diff --git a/src/App.tsx b/src/App.tsx index 62920a1..3299c30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,15 +38,15 @@ const s = { } as const, textarea: { width: "100%", - height: "72px", padding: "8px 12px", "font-size": "32px", border: "1px solid #d9d9d9", "border-radius": "6px", - resize: "vertical", + resize: "none", "box-sizing": "border-box", outline: "none", color: "#e74c3c", + "line-height": "1.4", } as const, pre: { background: "#f7f7f8", @@ -102,7 +102,7 @@ function App() { set_fonts(fontList); set_serverConfig(config); if (fontList.length > 0) { - set_selectedFont(fontList[0].name); + onFontChange(fontList[0].name); } }); @@ -119,28 +119,41 @@ function App() { }`; }); - /** 字体切换时使用 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", - }); - } + /** loadText loader 引用,字体或文本变化时增量加载 */ + let textLoader: { update: (text: string) => void; dispose: () => void } | null = null; + + /** 文本变化时增量加载字体 */ + const onTextChange = (value: string) => { + set_text(value); + if (!textLoader) return; + textLoader.update(value); + }; + + /** 根据文本行数动态计算 textarea 高度 */ + const textareaRows = createMemo(() => { + const lines = text().split("\n").length; + return Math.max(2, Math.min(lines, 10)); }); + /** 字体切换时为当前文本加载新字体 */ + const onFontChange = (font: string) => { + set_selectedFont(font); + if (!font) return; + if (textLoader) textLoader.dispose(); + textLoader = (globalThis as any).WebFont?.loadText({ + fontName: font, + text: text(), + family: "CustomFont", + }) ?? null; + const el = document.getElementById("webfont-preview"); + if (el) el.style.fontFamily = '"CustomFont", sans-serif'; + }; + async function refreshFonts() { const fontList = await fetchFonts(); set_fonts(fontList); if (fontList.length > 0 && !selectedFont()) { - set_selectedFont(fontList[0].name); + onFontChange(fontList[0].name); } } @@ -165,7 +178,7 @@ function App() {