---
name: 运行时字体管理器 (Runtime Font Manager Pro)
description: 现代精致的 Excalidraw 运行时字体管理面板。支持极速检索 600+ 本机系统字体、实时字体排版样张预览、一键加载入画布、网络字体下载与 Vault 持久化同步。
author: ymjr
version: 2.0.0
license: MIT
usage: 运行此脚本呼出专业字体管理悬浮面板。可在“系统字体”、“画布字体”、“网络下载”与“目录同步”间快速切换,支持即时搜索与所见即所得样张预览。
features:
- 自动极速扫描 600+ 本机系统字体(Windows/macOS/Linux),支持即时中文/英文/等宽筛选
- 所见即所得排版样张预览,点击即刻完成字体注册与应用
- 管理画布当前已注册字体,一键应用到当前选中文本
- 精选开源名牌字体一键下载(得意黑、霞鹜文楷、Fira Code 等),支持自定义 URL 下载并自动持久化至 Vault
- 手动触发 initializeFonts() 重新扫描加载多字体文件夹
autorun: false
---
/*
```javascript
*/
var locales = {
zh: {
ui_title: "运行时字体管理",
tab_system: "本机系统字体",
tab_canvas: "画布已载入",
tab_download: "网络下载",
tab_sync: "目录同步",
search_placeholder: "搜索字体家族或文件名 (如 楷体, YaHei, Consolas)...",
filter_all: "全部",
filter_cjk: "中文",
filter_mono: "代码等宽",
filter_popular: "常用推荐",
font_count_fmt: "共 {0} 个字体",
sample_cjk: "永和九年,岁在癸丑;天地玄黄,宇宙洪荒 0123456789",
sample_en: "The quick brown fox jumps over the lazy dog. 0123456789",
btn_load_font: "载入画布",
btn_loaded: "已在画布",
btn_apply: "应用到选中",
btn_reload_scan: "重新扫描多字体目录",
hint_reload: "重新扫描并编译 Vault 字体目录中的所有字体文件",
notice_select: "请先在画布中选中包含文本的元素!",
notice_applied: "已将字体 \"{0}\" 应用于 {1} 个文本元素",
preset_title: "精选开源字体快速安装",
chk_save_vault: "保存至 Vault 字体目录持久化",
btn_download: "下载并载入画布",
custom_url_label: "自定义网络字体 URL",
custom_name_label: "自定义字体名称 (留空自动从文件解析)",
status_downloading: "正在下载并注册字体...",
status_success: "成功注册字体: ",
status_failed: "操作失败: ",
no_results: "没有找到匹配的字体",
folder_path_label: "当前多字体目录: "
},
en: {
ui_title: "Runtime Font Manager",
tab_system: "System Fonts",
tab_canvas: "Canvas Fonts",
tab_download: "Web Download",
tab_sync: "Folder Sync",
search_placeholder: "Search family or file (e.g. Consolas, Arial, Inter)...",
filter_all: "All",
filter_cjk: "CJK / Chinese",
filter_mono: "Monospace",
filter_popular: "Popular",
font_count_fmt: "{0} fonts available",
sample_cjk: "永和九年,岁在癸丑;天地玄黄,宇宙洪荒 0123456789",
sample_en: "The quick brown fox jumps over the lazy dog. 0123456789",
btn_load_font: "Load Font",
btn_loaded: "Loaded",
btn_apply: "Apply to Selected",
btn_reload_scan: "Rescan Multi-Fonts Folder",
hint_reload: "Rescans and compiles all font files in your configured Vault font folder",
notice_select: "Please select text elements on canvas first!",
notice_applied: "Applied \"{0}\" to {1} text elements",
preset_title: "One-Click Open Source Presets",
chk_save_vault: "Save into Vault font folder for persistence",
btn_download: "Download & Register",
custom_url_label: "Custom Web Font URL",
custom_name_label: "Custom Font Name (leave blank to auto-detect)",
status_downloading: "Downloading and registering font...",
status_success: "Successfully registered font: ",
status_failed: "Operation failed: ",
no_results: "No matching fonts found",
folder_path_label: "Multi-Fonts folder path: "
}
};
const lang = (ea.plugin?.settings?.displayLanguage || (typeof window !== "undefined" && window.localStorage?.getItem("language")) || "zh").startsWith("zh") ? "zh" : "en";
const { Notice } = ea.obsidian;
// Professional Inline SVGs (No emoji icons)
const ICONS = {
search: '',
monitor: '',
canvas: '',
download: '',
folder: '',
refresh: '',
check: '',
plus: '',
close: '',
apply: ''
};
// Curated Open Source Web Fonts for 1-Click Install
const PRESET_WEB_FONTS = [
{
name: "Smiley Sans (得意黑)",
desc: "开源人文西文结合的中文字体,圆润舒展,极富设计美感",
url: "https://raw.githubusercontent.com/rewear-team/smiley-sans/main/packages/smiley-sans/dist/SmileySans-Oblique.ttf"
},
{
name: "LXGW WenKai (霞鹜文楷)",
desc: "优雅开源楷体,字形端庄清秀,技术讲解与图文注释首选",
url: "https://raw.githubusercontent.com/lxgw/LxgwWenKai/main/fonts/TTF/LXGWWenKai-Regular.ttf"
},
{
name: "Fira Code",
desc: "带编程连字的优秀等宽开源代码字体",
url: "https://raw.githubusercontent.com/tonsky/FiraCode/master/distr/ttf/FiraCode-Regular.ttf"
},
{
name: "JetBrains Mono",
desc: "专为程序员阅读优化的现代等宽代码字体",
url: "https://raw.githubusercontent.com/JetBrains/JetBrainsMono/master/fonts/ttf/JetBrainsMono-Regular.ttf"
}
];
function getSelectedTextElements() {
const api = ea.getExcalidrawAPI();
const elementsMap = api?.App?.scene?.getNonDeletedElementsMap?.() || new Map();
const selectedEls = ea.getViewSelectedElements();
const textEls = [];
for (const el of selectedEls) {
if (el.type === "text") {
textEls.push(el);
} else if (el.boundElements) {
for (const bound of el.boundElements) {
if (bound.type === "text") {
const textEl = elementsMap.get(bound.id);
if (textEl) textEls.push(textEl);
}
}
}
}
return textEls;
}
function applyFontToSelected(family) {
const textEls = getSelectedTextElements();
if (textEls.length === 0) {
new Notice(t("notice_select"));
return;
}
for (const el of textEls) {
el.fontFamily = family;
}
ea.copyViewElementsToEAforEditing(textEls);
ea.addElementsToView();
new Notice(t("notice_applied", family, textEls.length));
}
async function confirmSaveToVaultDialog(fontName) {
return new Promise((resolve) => {
const modalBg = document.createElement("div");
modalBg.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.45); z-index: 10000;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px);
`;
const dialog = document.createElement("div");
dialog.style.cssText = `
width: 420px; background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 12px; padding: 20px; box-shadow: 0 20px 48px rgba(0,0,0,0.3);
display: flex; flex-direction: column; gap: 14px;
font-family: var(--font-interface); font-size: 13px;
`;
dialog.innerHTML = `
⚠️
未勾选保存至 Vault 字体目录
你未勾选“保存字体至 Vault 字体目录”。字体 ${fontName} 将仅在当前会话的内存中加载。
⚠️ 风险提示:若未保存到 Vault 文件夹,重启 Obsidian 或在其他未安装该字体的设备(如平板、手机、其他电脑)上打开画布时,该字体将无法自动加载并回退显示。
建议保存到多字体文件夹以实现持久化与多端同步。
`;
modalBg.appendChild(dialog);
document.body.appendChild(modalBg);
dialog.querySelector("#dlg-cancel").onclick = () => {
modalBg.remove();
resolve(null);
};
dialog.querySelector("#dlg-session").onclick = () => {
modalBg.remove();
resolve(false);
};
dialog.querySelector("#dlg-save").onclick = () => {
modalBg.remove();
resolve(true);
};
});
}
function readFontFamilyFastScript(filePath, fsModule) {
let fd = null;
try {
fd = fsModule.openSync(filePath, "r");
const headerBuf = new Uint8Array(4096);
fsModule.readSync(fd, headerBuf, 0, 4096, 0);
const view = new DataView(headerBuf.buffer, headerBuf.byteOffset, headerBuf.byteLength);
let baseOffset = 0;
const sig = view.getUint32(0);
if (sig === 0x74746366) baseOffset = view.getUint32(12);
if (view.byteLength < baseOffset + 12) return null;
const tableCount = view.getUint16(baseOffset + 4);
let nameOffset = -1;
let nameLength = 0;
for (let i = 0; i < tableCount; i++) {
const rec = baseOffset + 12 + i * 16;
if (view.byteLength < rec + 16) break;
if (view.getUint32(rec) === 0x6e616d65) {
nameOffset = view.getUint32(rec + 8);
nameLength = view.getUint32(rec + 12);
break;
}
}
if (nameOffset === -1 || nameLength <= 6) return null;
const nameBuf = new Uint8Array(nameLength);
fsModule.readSync(fd, nameBuf, 0, nameLength, nameOffset);
const nView = new DataView(nameBuf.buffer, nameBuf.byteOffset, nameBuf.byteLength);
const count = nView.getUint16(2);
const stringOffset = nView.getUint16(4);
let preferredName = null;
let fallbackName = null;
for (let i = 0; i < count; i++) {
const rec = 6 + i * 12;
if (nView.byteLength < rec + 12) break;
const platformID = nView.getUint16(rec);
const languageID = nView.getUint16(rec + 4);
const nameID = nView.getUint16(rec + 6);
const length = nView.getUint16(rec + 8);
const strOff = stringOffset + nView.getUint16(rec + 10);
if (nView.byteLength < strOff + length) continue;
if (nameID === 1 || nameID === 4 || nameID === 16) {
let str = "";
if (platformID === 3 || platformID === 0) {
for (let b = 0; b < length; b += 2) str += String.fromCharCode(nView.getUint16(strOff + b));
} else if (platformID === 1) {
for (let b = 0; b < length; b++) str += String.fromCharCode(nView.getUint8(strOff + b));
}
str = str.trim();
if (!str) continue;
if (nameID === 1 && (languageID === 0x0409 || platformID === 0)) return str;
if (nameID === 1 && !preferredName) preferredName = str;
if (!fallbackName) fallbackName = str;
}
}
return preferredName || fallbackName;
} catch (e) {
return null;
} finally {
if (fd !== null) {
try { fsModule.closeSync(fd); } catch (e) {}
}
}
}
function scanSystemFontsDirectly() {
const electronWin = window;
const fs = typeof electronWin.require === "function" ? electronWin.require("fs") : (typeof require === "function" ? require("fs") : null);
const pathModule = typeof electronWin.require === "function" ? electronWin.require("path") : (typeof require === "function" ? require("path") : null);
if (!fs || !pathModule) return [];
const candidateDirs = [];
const nodeProcess = typeof process !== "undefined" ? process : (electronWin.process || {});
const env = nodeProcess.env || {};
const isWin = (typeof navigator !== "undefined" && /win/i.test(navigator.platform || navigator.userAgent)) || nodeProcess.platform === "win32";
const isMac = (typeof navigator !== "undefined" && /mac/i.test(navigator.platform || navigator.userAgent)) || nodeProcess.platform === "darwin";
if (isWin) {
const winDir = env.WINDIR || env.windir || env.SystemRoot || env.SYSTEMROOT || "C:\\Windows";
candidateDirs.push(pathModule.join(winDir, "Fonts"));
const localApp = env.LOCALAPPDATA || env.LocalAppData || env.localappdata;
if (localApp) {
candidateDirs.push(pathModule.join(localApp, "Microsoft", "Windows", "Fonts"));
}
} else if (isMac) {
candidateDirs.push("/System/Library/Fonts", "/Library/Fonts", pathModule.join(env.HOME || "~", "Library/Fonts"));
} else {
candidateDirs.push("/usr/share/fonts", "/usr/local/share/fonts", pathModule.join(env.HOME || "~", ".local/share/fonts"));
}
const results = [];
const seenFamilies = new Set();
const currentLoaded = new Set((ea.getFontFamilies ? ea.getFontFamilies() : []).map(f => f.toLowerCase()));
for (const dir of candidateDirs) {
if (!fs.existsSync(dir)) continue;
try {
const entries = fs.readdirSync(dir);
for (const entry of entries) {
if (!/\.(ttf|ttc|otf|woff2)$/i.test(entry)) continue;
const fullPath = pathModule.join(dir, entry);
const family = readFontFamilyFastScript(fullPath, fs) || entry.replace(/\.[^/.]+$/, "");
const key = family.toLowerCase();
if (!seenFamilies.has(key)) {
seenFamilies.add(key);
results.push({
family,
fileName: entry,
path: fullPath,
isLoaded: currentLoaded.has(key)
});
}
}
} catch (e) {}
}
results.sort((a, b) => a.family.localeCompare(b.family));
return results;
}
async function renderMainPanel() {
const PANEL_ID = "ea-runtime-font-manager-pro";
const existing = document.getElementById(PANEL_ID);
if (existing) existing.remove();
const panel = document.createElement("div");
panel.id = PANEL_ID;
panel.style.cssText = `
position: fixed;
top: 90px;
right: 48px;
width: 540px;
height: 640px;
max-height: 85vh;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.22), 0 4px 14px rgba(0, 0, 0, 0.08);
border-radius: 12px;
z-index: 9999;
display: flex;
flex-direction: column;
font-family: var(--font-interface);
font-size: 13px;
overflow: hidden;
backdrop-filter: blur(12px);
`;
// 1. Header (Draggable)
const header = document.createElement("div");
header.style.cssText = `
padding: 12px 18px;
background: var(--background-secondary);
cursor: move;
border-bottom: 1px solid var(--background-modifier-border);
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
border-radius: 12px 12px 0 0;
`;
header.innerHTML = `
${ICONS.canvas}
${t("ui_title")}
v2.0 Pro
`;
panel.appendChild(header);
// 2. Segmented Navigation Tabs
const navContainer = document.createElement("div");
navContainer.style.cssText = `
padding: 10px 18px 4px 18px;
background: var(--background-primary);
`;
const navTabs = document.createElement("div");
navTabs.style.cssText = `
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
padding: 3px;
border-radius: 8px;
display: flex;
gap: 2px;
`;
const tabsConfig = [
{ id: "system", label: t("tab_system"), icon: ICONS.monitor },
{ id: "canvas", label: t("tab_canvas"), icon: ICONS.canvas },
{ id: "download", label: t("tab_download"), icon: ICONS.download },
{ id: "sync", label: t("tab_sync"), icon: ICONS.folder }
];
let currentTab = "system";
const tabButtons = {};
// Content Area
const body = document.createElement("div");
body.style.cssText = `
flex: 1;
overflow-y: auto;
padding: 14px 18px 18px 18px;
display: flex;
flex-direction: column;
gap: 14px;
`;
tabsConfig.forEach(tab => {
const btn = document.createElement("button");
btn.style.cssText = `
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
border: none;
cursor: pointer;
color: var(--text-muted);
background: transparent;
transition: all 0.15s ease;
`;
btn.innerHTML = `${tab.icon}${tab.label}`;
btn.onclick = () => switchTab(tab.id);
tabButtons[tab.id] = btn;
navTabs.appendChild(btn);
});
navContainer.appendChild(navTabs);
panel.appendChild(navContainer);
panel.appendChild(body);
// Cache state
let systemFontsList = [];
let isScanningSystem = false;
async function switchTab(tabId) {
currentTab = tabId;
tabsConfig.forEach(tConf => {
const b = tabButtons[tConf.id];
if (tConf.id === tabId) {
b.style.background = "var(--background-primary)";
b.style.color = "var(--text-normal)";
b.style.boxShadow = "0 1px 3px rgba(0,0,0,0.1)";
b.style.fontWeight = "600";
} else {
b.style.background = "transparent";
b.style.color = "var(--text-muted)";
b.style.boxShadow = "none";
b.style.fontWeight = "500";
}
});
body.innerHTML = "";
if (tabId === "system") {
await renderSystemFontsTab(body);
} else if (tabId === "canvas") {
renderCanvasFontsTab(body);
} else if (tabId === "download") {
renderDownloadTab(body);
} else if (tabId === "sync") {
renderSyncTab(body);
}
}
// --- TAB 1: SYSTEM FONTS (600+ FULL MACHINE FONTS) ---
async function renderSystemFontsTab(container) {
// Top Controls: Search Bar & Filters
const topCard = document.createElement("div");
topCard.style.cssText = "display:flex; flex-direction:column; gap:10px;";
const searchRow = document.createElement("div");
searchRow.style.cssText = "position:relative; display:flex; align-items:center;";
searchRow.innerHTML = `
${ICONS.search}
`;
topCard.appendChild(searchRow);
// Options Row: Save to Vault Checkbox
const optionsRow = document.createElement("div");
optionsRow.style.cssText = "display:flex; justify-content:space-between; align-items:center; padding:2px 0;";
optionsRow.innerHTML = `
`;
topCard.appendChild(optionsRow);
// Filter Chips Row
const filterRow = document.createElement("div");
filterRow.style.cssText = "display:flex; justify-content:space-between; align-items:center;";
const chipsContainer = document.createElement("div");
chipsContainer.style.cssText = "display:flex; gap:6px;";
const filters = [
{ id: "all", label: t("filter_all") },
{ id: "popular", label: t("filter_popular") },
{ id: "cjk", label: t("filter_cjk") },
{ id: "mono", label: t("filter_mono") }
];
let activeFilter = "all";
const countBadge = document.createElement("span");
countBadge.style.cssText = "font-size:11px; color:var(--text-muted);";
filterRow.appendChild(chipsContainer);
filterRow.appendChild(countBadge);
topCard.appendChild(filterRow);
container.appendChild(topCard);
// List Container
const listWrapper = document.createElement("div");
listWrapper.style.cssText = "flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:8px; padding-right:4px;";
container.appendChild(listWrapper);
// Load data
if (systemFontsList.length === 0 && !isScanningSystem) {
isScanningSystem = true;
listWrapper.innerHTML = `
${ICONS.refresh}
正在高速扫描本机已安装字体 (约 0.1~0.3s)...
`;
try {
if (typeof ea.getSystemFonts === "function") {
systemFontsList = await ea.getSystemFonts();
} else if (ea.plugin && typeof ea.plugin.getSystemFonts === "function") {
systemFontsList = await ea.plugin.getSystemFonts();
}
} catch (e) {
console.warn("ea.getSystemFonts failed, falling back to direct scan:", e);
}
if (!systemFontsList || systemFontsList.length === 0) {
systemFontsList = scanSystemFontsDirectly();
}
isScanningSystem = false;
}
const searchInput = searchRow.querySelector("#sys-search");
const clearBtn = searchRow.querySelector("#clear-search");
searchInput.oninput = () => {
clearBtn.style.display = searchInput.value ? "block" : "none";
renderList();
};
clearBtn.onclick = () => {
searchInput.value = "";
clearBtn.style.display = "none";
renderList();
};
filters.forEach(f => {
const chip = document.createElement("button");
chip.style.cssText = `
padding: 3px 9px;
border-radius: 9999px;
font-size: 11px;
cursor: pointer;
border: 1px solid var(--background-modifier-border);
background: ${f.id === activeFilter ? "var(--interactive-accent)" : "var(--background-secondary)"};
color: ${f.id === activeFilter ? "var(--text-on-accent)" : "var(--text-muted)"};
font-weight: ${f.id === activeFilter ? "600" : "400"};
transition: all 0.12s ease;
`;
chip.innerText = f.label;
chip.onclick = () => {
activeFilter = f.id;
chipsContainer.querySelectorAll("button").forEach((b, i) => {
const match = filters[i].id === activeFilter;
b.style.background = match ? "var(--interactive-accent)" : "var(--background-secondary)";
b.style.color = match ? "var(--text-on-accent)" : "var(--text-muted)";
b.style.fontWeight = match ? "600" : "400";
});
renderList();
};
chipsContainer.appendChild(chip);
});
const POPULAR_KEYS = ["kaiti", "simhei", "yahei", "simsun", "fangsong", "deng", "consolas", "cascadia", "arial", "inter", "fira", "segoe"];
const MONO_KEYS = ["code", "mono", "consolas", "courier", "fixed"];
const CJK_KEYS = ["yahei", "kaiti", "simhei", "simsun", "fangsong", "deng", "pingfang", "songti", "heiti", "stkaiti", "lxgw", "楷", "黑", "宋", "仿宋"];
function renderList() {
listWrapper.innerHTML = "";
const term = searchInput.value.trim().toLowerCase();
const currentCanvasFonts = new Set((ea.getFontFamilies ? ea.getFontFamilies() : []).map(f => f.toLowerCase()));
let filtered = systemFontsList.filter(item => {
const famLower = item.family.toLowerCase();
const fileLower = item.fileName.toLowerCase();
// search term
if (term && !famLower.includes(term) && !fileLower.includes(term)) {
return false;
}
// categories
if (activeFilter === "popular") {
return POPULAR_KEYS.some(k => famLower.includes(k) || fileLower.includes(k));
}
if (activeFilter === "cjk") {
return CJK_KEYS.some(k => famLower.includes(k) || fileLower.includes(k));
}
if (activeFilter === "mono") {
return MONO_KEYS.some(k => famLower.includes(k) || fileLower.includes(k));
}
return true;
});
countBadge.innerText = t("font_count_fmt", filtered.length);
if (filtered.length === 0) {
listWrapper.innerHTML = `
${t("no_results")}
`;
return;
}
// Max render 60 items at a time for buttery smooth rendering
const displayBatch = filtered.slice(0, 60);
displayBatch.forEach(font => {
const isLoaded = currentCanvasFonts.has(font.family.toLowerCase());
const card = document.createElement("div");
card.style.cssText = `
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
transition: border-color 0.15s ease;
`;
// Card Top: Family Name, Filename, Action Button
const headerRow = document.createElement("div");
headerRow.style.cssText = "display:flex; justify-content:space-between; align-items:center;";
headerRow.innerHTML = `
${font.family}
${font.fileName}
`;
const actionBtn = document.createElement("button");
if (isLoaded) {
actionBtn.style.cssText = "padding:3px 8px; font-size:11px; border-radius:4px; border:1px solid var(--background-modifier-border); background:var(--background-primary); color:var(--text-muted); cursor:default; display:flex; align-items:center; gap:4px;";
actionBtn.innerHTML = `${ICONS.check}${t("btn_loaded")}`;
} else {
actionBtn.style.cssText = "padding:4px 10px; font-size:11px; font-weight:500; border-radius:4px; border:none; background:var(--interactive-accent); color:var(--text-on-accent); cursor:pointer; display:flex; align-items:center; gap:4px; transition:transform 0.1s ease;";
actionBtn.innerHTML = `${ICONS.plus}${t("btn_load_font")}`;
actionBtn.onclick = async () => {
const chkSaveVault = topCard.querySelector("#sys-save-vault");
let saveToVault = chkSaveVault ? chkSaveVault.checked : true;
if (!saveToVault) {
const choice = await confirmSaveToVaultDialog(font.family);
if (choice === null) {
return;
}
saveToVault = choice;
if (choice === true && chkSaveVault) {
chkSaveVault.checked = true;
}
}
actionBtn.disabled = true;
actionBtn.innerText = "⏳...";
try {
let regName = "";
if (typeof ea.registerFont === "function") {
regName = await ea.registerFont({ path: font.path, name: font.family, saveToVault });
} else if (ea.plugin && typeof ea.plugin.registerFont === "function") {
regName = await ea.plugin.registerFont({ path: font.path, name: font.family, saveToVault });
}
new Notice(t("status_success") + regName);
const selected = getSelectedTextElements();
if (selected.length > 0) {
applyFontToSelected(regName);
}
renderList();
} catch (err) {
new Notice(t("status_failed") + err.message);
actionBtn.disabled = false;
actionBtn.innerHTML = `${ICONS.plus}${t("btn_load_font")}`;
}
};
}
headerRow.appendChild(actionBtn);
card.appendChild(headerRow);
// Card Bottom: Typography Sample in its true font
const sampleRow = document.createElement("div");
const isCjk = CJK_KEYS.some(k => font.family.toLowerCase().includes(k) || font.fileName.toLowerCase().includes(k));
sampleRow.style.cssText = `
font-family: "${font.family}", sans-serif;
font-size: 13px;
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 4px 0 2px 0;
opacity: 0.9;
`;
sampleRow.innerText = isCjk ? t("sample_cjk") : t("sample_en");
card.appendChild(sampleRow);
listWrapper.appendChild(card);
});
}
renderList();
}
// --- TAB 2: CANVAS ACTIVE FONTS ---
function renderCanvasFontsTab(container) {
const families = ea.getFontFamilies ? ea.getFontFamilies() : ["Virgil", "Helvetica", "Cascadia"];
const topBar = document.createElement("div");
topBar.style.cssText = "display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;";
topBar.innerHTML = `
${t("font_count_fmt", families.length)}
`;
container.appendChild(topBar);
let selectedFamily = families[0] || "Virgil";
const listWrapper = document.createElement("div");
listWrapper.style.cssText = "flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:8px;";
families.forEach(fam => {
const card = document.createElement("div");
const isSelected = fam === selectedFamily;
card.style.cssText = `
background: var(--background-secondary);
border: 1px solid ${isSelected ? "var(--interactive-accent)" : "var(--background-modifier-border)"};
border-radius: 8px;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
transition: all 0.15s ease;
`;
card.onclick = () => {
selectedFamily = fam;
renderCanvasFontsTab(container);
};
const rowTop = document.createElement("div");
rowTop.style.cssText = "display:flex; justify-content:space-between; align-items:center;";
rowTop.innerHTML = `
${fam}
`;
rowTop.querySelector("button").onclick = (e) => {
e.stopPropagation();
applyFontToSelected(fam);
};
const sample = document.createElement("div");
sample.style.cssText = `
font-family: "${fam}", sans-serif;
font-size: 14px;
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
sample.innerText = t("sample_cjk");
card.appendChild(rowTop);
card.appendChild(sample);
listWrapper.appendChild(card);
});
container.appendChild(listWrapper);
topBar.querySelector("#apply-all-selected").onclick = () => {
applyFontToSelected(selectedFamily);
};
}
// --- TAB 3: WEB FONT DOWNLOAD & PRESETS ---
function renderDownloadTab(container) {
const card = document.createElement("div");
card.style.cssText = "display:flex; flex-direction:column; gap:12px;";
// Custom Download Form
const customSection = document.createElement("div");
customSection.style.cssText = "background:var(--background-secondary); border:1px solid var(--background-modifier-border); border-radius:8px; padding:14px; display:flex; flex-direction:column; gap:10px;";
customSection.innerHTML = `
${t("custom_url_label")}
${t("custom_name_label")}
`;
card.appendChild(customSection);
// Presets Section
const presetSection = document.createElement("div");
presetSection.style.cssText = "display:flex; flex-direction:column; gap:8px;";
presetSection.innerHTML = `${t("preset_title")}
`;
PRESET_WEB_FONTS.forEach(pf => {
const pCard = document.createElement("div");
pCard.style.cssText = "background:var(--background-secondary); border:1px solid var(--background-modifier-border); border-radius:8px; padding:10px 14px; display:flex; justify-content:space-between; align-items:center;";
pCard.innerHTML = `
${pf.name}
${pf.desc}
`;
const dlBtn = pCard.querySelector(".preset-dl-btn");
dlBtn.onclick = async () => {
dlBtn.disabled = true;
dlBtn.innerText = "⏳...";
try {
const regName = await ea.registerFont({
url: pf.url,
name: pf.name.split(" ")[0],
saveToVault: true
});
new Notice(t("status_success") + regName);
dlBtn.innerHTML = `${ICONS.check}已载入`;
} catch (e) {
new Notice(t("status_failed") + e.message);
dlBtn.disabled = false;
dlBtn.innerHTML = `${ICONS.download} 安装`;
}
};
presetSection.appendChild(pCard);
});
card.appendChild(presetSection);
container.appendChild(card);
// Custom download event
const btnDl = customSection.querySelector("#btn-custom-download");
btnDl.onclick = async () => {
const url = customSection.querySelector("#web-url").value.trim();
const name = customSection.querySelector("#web-name").value.trim();
let saveToVault = customSection.querySelector("#web-save-vault").checked;
if (!url) {
new Notice("Please enter a valid font URL.");
return;
}
if (!saveToVault) {
const choice = await confirmSaveToVaultDialog(name || url.split("/").pop());
if (choice === null) return;
saveToVault = choice;
if (choice === true) {
customSection.querySelector("#web-save-vault").checked = true;
}
}
btnDl.disabled = true;
btnDl.innerText = t("status_downloading");
try {
const regName = await ea.registerFont({ url, name: name || undefined, saveToVault });
new Notice(t("status_success") + regName);
customSection.querySelector("#web-url").value = "";
customSection.querySelector("#web-name").value = "";
} catch (e) {
new Notice(t("status_failed") + e.message);
} finally {
btnDl.disabled = false;
btnDl.innerHTML = `${ICONS.download} ${t("btn_download")}`;
}
};
}
// --- TAB 4: FOLDER SYNC ---
function renderSyncTab(container) {
const card = document.createElement("div");
card.style.cssText = "background:var(--background-secondary); border:1px solid var(--background-modifier-border); border-radius:8px; padding:18px; display:flex; flex-direction:column; gap:14px;";
const folderPath = ea.plugin?.settings?.multiFontsFolder || "未配置 (Not Configured)";
card.innerHTML = `
${ICONS.folder}
${t("folder_path_label")}
${folderPath}
${t("hint_reload")}。当你往该 Vault 文件夹内放入新的 .ttf、.woff2、.otf 或 .ttc 文件后,点击下方按钮无需重启 Obsidian 即可热重载所有字体。
`;
const btnSync = card.querySelector("#btn-sync-now");
btnSync.onclick = async () => {
btnSync.disabled = true;
btnSync.innerHTML = `${ICONS.refresh} 扫描重载中...`;
try {
await ea.initializeFonts(true);
// Refresh system cache too
if (typeof ea.getSystemFonts === "function") {
systemFontsList = await ea.getSystemFonts(true);
}
} catch (err) {
new Notice("Sync Error: " + err.message);
} finally {
btnSync.disabled = false;
btnSync.innerHTML = `${ICONS.refresh} ${t("btn_reload_scan")}`;
}
};
container.appendChild(card);
}
// Initial tab render
switchTab("system");
// Draggable Header & Close Event
header.querySelector("#close-btn").onclick = () => panel.remove();
// Escape key closes panel
const escHandler = (e) => {
if (e.key === "Escape") {
panel.remove();
window.removeEventListener("keydown", escHandler);
}
};
window.addEventListener("keydown", escHandler);
let isDragging = false, startX = 0, startY = 0, initLeft = 0, initTop = 0;
header.onmousedown = (e) => {
if (e.target.tagName === "BUTTON") return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = panel.getBoundingClientRect();
initLeft = rect.left;
initTop = rect.top;
panel.style.right = "auto";
panel.style.left = `${initLeft}px`;
panel.style.top = `${initTop}px`;
e.preventDefault();
};
const onMouseMove = (e) => {
if (!isDragging) return;
panel.style.left = `${initLeft + (e.clientX - startX)}px`;
panel.style.top = `${initTop + (e.clientY - startY)}px`;
};
const onMouseUp = () => { isDragging = false; };
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
// Keyframes for spin animation
if (!document.getElementById("ea-font-mgr-style")) {
const styleEl = document.createElement("style");
styleEl.id = "ea-font-mgr-style";
styleEl.innerHTML = `@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`;
document.head.appendChild(styleEl);
}
document.body.appendChild(panel);
}
renderMainPanel();