// ==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 => `
${font.name}
`).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(); } })();