// ==UserScript==
// @name 网页字体修改器
// @namespace http://via-browser.com/
// @version 3.0
// @description 导入本地字体更换网页字体,支持悬浮球控制和自定义字体颜色
// @author ^o^
// @run-at document-start
// @match *://*/*
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @downloadURL https://update.greasyfork.icu/scripts/537113/%E7%BD%91%E9%A1%B5%E5%AD%97%E4%BD%93%E4%BF%AE%E6%94%B9%E5%99%A8.user.js
// @updateURL https://update.greasyfork.icu/scripts/537113/%E7%BD%91%E9%A1%B5%E5%AD%97%E4%BD%93%E4%BF%AE%E6%94%B9%E5%99%A8.meta.js
// ==/UserScript==
(function() {
'use strict';
const DEFAULT_COLOR = '#333333';
const FAB_SIZE = 50;
const main = () => {
const defaultFont = { name: 'system-ui(默认)', fontFamily: 'system-ui', isDefault: true };
const fontData = GM_getValue('fontData', { fonts: [defaultFont], currentFont: defaultFont.name, fontColor: DEFAULT_COLOR, fabPosition: null });
const cachedFontBlobUrls = {};
let fab = null;
let panel = null;
let overlay = null;
let isFabVisible = GM_getValue('fabVisible', true);
GM_addStyle(`
#via-font-fab { position: fixed; width: ${FAB_SIZE}px; height: ${FAB_SIZE}px; background: linear-gradient(45deg, #2196F3, #9C27B0); color: white; border-radius: 50%; text-align: center; line-height: ${FAB_SIZE}px; font-size: 24px; font-weight: bold; box-shadow: 0 4px 8px rgba(0,0,0,0.3); z-index: 999999; touch-action: none; user-select: none; transition: all 0.2s; opacity: 0.7; transform: scale(0.8); }
#via-font-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 999997; display: none; opacity: 0; transition: opacity 0.3s ease; }
#via-font-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.8); background: white; border-radius: 16px; box-shadow: 0 12px 30px rgba(0,0,0,0.25); padding: 25px; max-height: 80vh; overflow-y: auto; z-index: 999998; width: 90%; max-width: 450px; opacity: 0; transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); display: none; }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #eee; }
.panel-title { margin: 0; font-weight: 600; color: #333; font-size: 20px; }
.close-btn { background: none; border: none; font-size: 26px; cursor: pointer; color: #999; transition: color 0.2s; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; border-radius: 50%; }
.close-btn:hover { background: #f5f5f5; color: #666; }
.setting-group { margin-bottom: 20px; }
.setting-label { display: block; margin-bottom: 8px; font-weight: 500; color: #555; font-size: 15px; }
.font-select { width: 100%; padding: 12px 15px; border-radius: 10px; border: 1px solid #ddd; font-size: 16px; background: #fff; box-shadow: 0 2px 5px rgba(0,0,0,0.05); transition: border-color 0.2s; }
.font-select:focus { border-color: #2196F3; outline: none; }
.color-controls { display: flex; align-items: center; gap: 10px; }
.color-picker { width: 50px; height: 40px; padding: 2px; border-radius: 8px; border: 1px solid #ddd; background: #fff; cursor: pointer; }
.color-input { flex: 1; padding: 10px 15px; border-radius: 10px; border: 1px solid #ddd; font-size: 14px; background: #fff; box-shadow: 0 2px 5px rgba(0,0,0,0.05); }
.color-reset-btn { padding: 8px 15px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 8px; cursor: pointer; transition: all 0.2s; }
.color-reset-btn:hover { background: #e0e0e0; }
.upload-area { display: flex; justify-content: center; margin: 15px 0; }
.upload-btn { display: inline-block; padding: 12px 30px; background: #4CAF50; color: white; border: none; border-radius: 10px; font-size: 16px; font-weight: 500; cursor: pointer; transition: all 0.2s; box-shadow: 0 3px 6px rgba(0,0,0,0.1); }
.upload-btn:hover { background: #45a049; transform: translateY(-2px); box-shadow: 0 5px 10px rgba(0,0,0,0.15); }
.upload-btn:active { transform: translateY(0); box-shadow: 0 3px 6px rgba(0,0,0,0.1); }
.upload-input { display: none; }
.installed-fonts-title { margin-bottom: 15px; font-size: 17px; color: #444; }
.fonts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 15px; }
.font-item { background: #f5f5f5; padding: 10px; border-radius: 12px; text-align: center; transition: all 0.2s; }
.font-item:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.font-name { font-size: 15px; margin-bottom: 8px; color: #333; font-weight: 500; }
.delete-btn { padding: 8px 15px; background: #ff4d4f; color: white; border: none; border-radius: 8px; cursor: pointer; width: 100%; transition: background 0.2s; }
.delete-btn:hover { background: #ff3336; }
@media (max-width: 500px) { #via-font-panel { width: 95%; padding: 20px 15px; } .fonts-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } }
`);
const createFAB = () => {
fab = document.createElement('div');
fab.id = 'via-font-fab';
if (fontData.fabPosition) {
fab.style.left = `${fontData.fabPosition.x}px`;
fab.style.top = `${fontData.fabPosition.y}px`;
} else {
fab.style.right = '20px';
fab.style.bottom = '30px';
}
fab.innerHTML = 'Aa';
document.body.appendChild(fab);
if (!isFabVisible) {
fab.style.display = 'none';
}
return fab;
};
const createPanel = () => {
overlay = document.createElement('div');
overlay.id = 'via-font-overlay';
document.body.appendChild(overlay);
panel = document.createElement('div');
panel.id = 'via-font-panel';
panel.innerHTML = `
`;
document.body.appendChild(panel);
return panel;
};
const setupDrag = () => {
let startX, startY, initialX, initialY;
let dragging = false;
let fabTimer = null;
let edgeTimer = null;
const onTouchStart = (e) => {
if (e.touches[0]) {
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
initialX = fab.offsetLeft;
initialY = fab.offsetTop;
fab.style.transition = 'none';
fab.style.opacity = '1';
fab.style.transform = 'scale(1)';
clearTimeout(fabTimer);
clearTimeout(edgeTimer);
document.addEventListener('touchmove', onTouchMove);
document.addEventListener('touchend', onTouchEnd);
}
};
const onTouchMove = (e) => {
if (e.touches[0]) {
const touch = e.touches[0];
const diffX = touch.clientX - startX;
const diffY = touch.clientY - startY;
if (Math.abs(diffX) > 5 || Math.abs(diffY) > 5) {
dragging = true;
}
let newX = initialX + diffX;
let newY = initialY + diffY;
const maxX = window.innerWidth - fab.offsetWidth;
const maxY = window.innerHeight - fab.offsetHeight;
newX = Math.max(0, Math.min(newX, maxX));
newY = Math.max(0, Math.min(newY, maxY));
fab.style.left = `${newX}px`;
fab.style.top = `${newY}px`;
fab.style.right = 'auto';
fab.style.bottom = 'auto';
}
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
fab.style.transition = 'left 0.2s, top 0.2s, transform 0.2s ease, opacity 0.2s ease';
if (dragging) {
fontData.fabPosition = { x: fab.offsetLeft, y: fab.offsetTop };
GM_setValue('fontData', fontData);
dragging = false;
}
clearTimeout(fabTimer);
fabTimer = setTimeout(() => {
fab.style.opacity = '0.7';
fab.style.transform = 'scale(0.8)';
edgeTimer = setTimeout(() => checkEdge(), 100);
}, 800);
};
fab.addEventListener('touchstart', onTouchStart);
};
const checkEdge = () => {
if (!fab) return;
const fabRect = fab.getBoundingClientRect();
const windowWidth = window.innerWidth;
const edgeThreshold = 10;
if (fabRect.left < edgeThreshold) {
fab.style.transform = 'scale(0.8) translateX(-40%)';
fab.style.opacity = '0.5';
} else if (windowWidth - fabRect.right < edgeThreshold) {
fab.style.transform = 'scale(0.8) translateX(40%)';
fab.style.opacity = '0.5';
} else {
fab.style.transform = 'scale(0.8)';
fab.style.opacity = '0.7';
}
};
const createStyleElement = (elementId) => {
let styleElement = document.getElementById(elementId);
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = elementId;
document.head.appendChild(styleElement);
}
return styleElement;
};
const fontFaceStyleElement = createStyleElement('font-face-style');
const commonStyleElement = createStyleElement('font-common-style');
const colorStyleElement = createStyleElement('font-color-style');
const updateCommonStyles = () => {
const selectedFont = fontData.fonts.find(font => font.name === fontData.currentFont);
if (!selectedFont) return;
const cssRules = `html *:not(i):not(em):not(:empty) { font-family: "${selectedFont.fontFamily}" !important; }`;
commonStyleElement.textContent = cssRules;
};
const applyColor = () => {
if (fontData.fontColor === DEFAULT_COLOR) {
colorStyleElement.textContent = '';
return;
}
colorStyleElement.textContent = `body, body * { color: ${fontData.fontColor} !important; }`;
};
const updateFontFaces = (selectedFont) => {
if (!selectedFont || !selectedFont.storageKey) {
fontFaceStyleElement.textContent = '';
updateCommonStyles();
return;
}
const fontBlobUrl = cachedFontBlobUrls[selectedFont.storageKey] || '';
if (fontBlobUrl) {
const fontFaceCss = `@font-face { font-family: "${selectedFont.fontFamily}"; src: url(${fontBlobUrl}) format('${selectedFont.format}'); }`;
fontFaceStyleElement.textContent = fontFaceCss;
updateCommonStyles();
return;
}
const fontChunks = GM_getValue(`font_${selectedFont.storageKey}_chunks`, []);
const totalChunks = GM_getValue(`font_${selectedFont.storageKey}_total`, 0);
if (fontChunks.length === totalChunks) {
Promise.all(fontChunks.map(index => GM_getValue(`font_${selectedFont.storageKey}_chunk_${index}`)))
.then(base64Chunks => {
const base64Data = base64Chunks.join('');
const blob = base64ToBlob(base64Data, selectedFont.mimeType);
const fontBlobUrl = URL.createObjectURL(blob);
cachedFontBlobUrls[selectedFont.storageKey] = fontBlobUrl;
const fontFaceCss = `@font-face { font-family: "${selectedFont.fontFamily}"; src: url(${fontBlobUrl}) format('${selectedFont.format}'); }`;
fontFaceStyleElement.textContent = fontFaceCss;
updateCommonStyles();
});
}
};
const togglePanel = () => {
if (panel.style.display === 'none' || panel.style.display === '') {
overlay.style.display = 'block';
panel.style.display = 'block';
setTimeout(() => {
overlay.style.opacity = '1';
panel.style.opacity = '1';
panel.style.transform = 'translate(-50%, -50%) scale(1)';
}, 10);
refreshPanel();
} else {
overlay.style.opacity = '0';
panel.style.opacity = '0';
panel.style.transform = 'translate(-50%, -50%) scale(0.8)';
setTimeout(() => {
overlay.style.display = 'none';
panel.style.display = 'none';
}, 300);
}
};
const refreshPanel = () => {
const content = panel.querySelector('.panel-content');
if (!content) return;
content.innerHTML = `
已安装字体 (${fontData.fonts.length})
${fontData.fonts.filter(f => !f.isDefault).map(font => `
`).join('')}
`;
setupPanelEvents();
};
const setupPanelEvents = () => {
panel.querySelector('.font-select').addEventListener('change', e => {
fontData.currentFont = e.target.value;
const selectedFont = fontData.fonts.find(f => f.name === fontData.currentFont);
if (selectedFont) {
updateFontFaces(selectedFont);
GM_setValue('fontData', fontData);
}
});
const colorPicker = panel.querySelector('.color-picker');
const colorInput = panel.querySelector('.color-input');
const colorResetBtn = panel.querySelector('.color-reset-btn');
colorPicker.addEventListener('input', e => {
fontData.fontColor = e.target.value;
colorInput.value = e.target.value;
applyColor();
GM_setValue('fontData', fontData);
});
colorInput.addEventListener('input', e => {
const value = e.target.value;
if (/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(value)) {
fontData.fontColor = value;
colorPicker.value = value;
applyColor();
GM_setValue('fontData', fontData);
}
});
colorResetBtn.addEventListener('click', () => {
fontData.fontColor = DEFAULT_COLOR;
colorPicker.value = DEFAULT_COLOR;
colorInput.value = DEFAULT_COLOR;
applyColor();
GM_setValue('fontData', fontData);
});
const uploadBtn = panel.querySelector('.upload-btn');
const uploadInput = panel.querySelector('.upload-input');
uploadBtn.addEventListener('click', () => {
uploadInput.click();
});
uploadInput.addEventListener('change', e => {
handleFontUpload(Array.from(e.target.files));
e.target.value = '';
});
panel.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
const fontName = btn.dataset.font;
handleDeleteFont(fontName);
});
});
panel.querySelector('.close-btn').addEventListener('click', togglePanel);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
togglePanel();
}
});
};
const handleFontUpload = async (files) => {
for (const file of files) {
await processFontFile(file);
}
refreshPanel();
};
const processFontFile = (file) => {
return new Promise((resolve) => {
const originalName = file.name.replace(/\.[^/.]+$/, "");
const extension = file.name.slice(file.name.lastIndexOf('.'));
let newName = originalName;
const existingFont = fontData.fonts.find(f => f.name === originalName);
if (existingFont) {
if (existingFont.fileSize === file.size) {
alert(`字体 "${originalName}" 已存在,无需重复导入。`);
resolve();
return;
} else {
let userProvidedName = prompt(`字体名称 "${originalName}" 已存在,需重新命名:`, originalName);
if (!userProvidedName) {
resolve();
return;
}
let index = 2;
newName = userProvidedName;
while (fontData.fonts.some(f => f.name === newName)) {
newName = `${userProvidedName}(${index})`;
index++;
}
}
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
const base64Data = result.split(',')[1];
const mimeType = result.split(',')[0].split(':')[1];
const storageKey = 'font_' + Date.now();
const format = getFontFormat(file.name);
const chunkSize = 500000;
const chunks = [];
for (let i = 0; i < base64Data.length; i += chunkSize) {
const chunk = base64Data.substring(i, i + chunkSize);
GM_setValue(`font_${storageKey}_chunk_${chunks.length}`, chunk);
chunks.push(chunks.length);
}
GM_setValue(`font_${storageKey}_chunks`, chunks);
GM_setValue(`font_${storageKey}_total`, chunks.length);
fontData.fonts.push({
name: newName,
fontFamily: newName,
originalFileName: file.name,
mimeType: mimeType,
storageKey: storageKey,
format: format,
fileSize: file.size
});
fontData.currentFont = newName;
GM_setValue('fontData', fontData);
const selectedFont = fontData.fonts.find(f => f.name === newName);
if (selectedFont) {
updateFontFaces(selectedFont);
}
resolve();
};
reader.readAsDataURL(file);
});
};
const handleDeleteFont = (fontName) => {
if (!confirm(`确定要删除字体 "${fontName}" 吗?`)) return;
const fontIndex = fontData.fonts.findIndex(f => f.name === fontName);
if (fontIndex === -1) return;
const font = fontData.fonts[fontIndex];
if (font.storageKey) {
const fontChunks = GM_getValue(`font_${font.storageKey}_chunks`, []);
fontChunks.forEach((_, i) => GM_deleteValue(`font_${font.storageKey}_chunk_${i}`));
GM_deleteValue(`font_${font.storageKey}_chunks`);
GM_deleteValue(`font_${font.storageKey}_total`);
if (cachedFontBlobUrls[font.storageKey]) {
URL.revokeObjectURL(cachedFontBlobUrls[font.storageKey]);
delete cachedFontBlobUrls[font.storageKey];
}
}
fontData.fonts.splice(fontIndex, 1);
if (fontData.currentFont === fontName) {
fontData.currentFont = fontData.fonts[0].name;
}
GM_setValue('fontData', fontData);
const selectedFont = fontData.fonts.find(f => f.name === fontData.currentFont);
if (selectedFont) {
updateFontFaces(selectedFont);
}
refreshPanel();
};
const base64ToBlob = (base64String, mimeType) => {
const byteCharacters = atob(base64String);
const byteArrays = [];
for (let i = 0; i < byteCharacters.length; i += 512) {
const slice = byteCharacters.slice(i, i + 512);
const byteNumbers = new Array(slice.length);
for (let j = 0; j < slice.length; j++) {
byteNumbers[j] = slice.charCodeAt(j);
}
byteArrays.push(new Uint8Array(byteNumbers));
}
return new Blob(byteArrays, { type: mimeType });
};
const getFontFormat = (fileName) => {
const ext = fileName.split('.').pop().toLowerCase();
return {
'ttf': 'truetype',
'otf': 'opentype',
'woff': 'woff',
'woff2': 'woff2'
}[ext] || 'truetype';
};
fab = createFAB();
createPanel();
setupDrag();
window.addEventListener('resize', checkEdge);
checkEdge();
fab.addEventListener('click', togglePanel);
const selectedFont = fontData.fonts.find(font => font.name === fontData.currentFont);
if (selectedFont) {
updateFontFaces(selectedFont);
}
applyColor();
GM_registerMenuCommand('🎨 打开字体设置', togglePanel);
GM_registerMenuCommand('🔄 切换悬浮球显示', () => {
isFabVisible = !isFabVisible;
fab.style.display = isFabVisible ? 'block' : 'none';
GM_setValue('fabVisible', isFabVisible);
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main);
} else {
main();
}
})();