移动端网页字体修改器

支持字体和颜色管理的移动端字体工具

目前為 2025-06-01 提交的版本,檢視 最新版本

// ==UserScript==
// @name         移动端网页字体修改器
// @namespace    http://via-browser.com/
// @version      2.3
// @description  支持字体和颜色管理的移动端字体工具
// @author       ^o^
// @match        *://*/*
// @run-at       document-start
// @grant        GM_registerMenuCommand
// ==/UserScript==

// 配置参数
const CONFIG = {
    DB_NAME: 'VIA_FONT_DB',
    FONT_TYPES: {
        'ttf':  { format: 'truetype' },
        'otf':  { format: 'opentype' },
        'woff': { format: 'woff' },
        'woff2':{ format: 'woff2' }
    }
};

// IndexedDB存储管理
class DatabaseManager {
    constructor() {
        this.db = null;
        this.dbName = CONFIG.DB_NAME;
        this.storeName = 'fontStore';
    }

    init() {
        return new Promise((resolve, reject) => {
            const request = window.indexedDB.open(this.dbName, 1);
            
            request.onupgradeneeded = function(event) {
                const db = event.target.result;
                if (!db.objectStoreNames.contains(this.storeName)) {
                    db.createObjectStore(this.storeName, { keyPath: 'id' });
                }
            }.bind(this);
            
            request.onsuccess = function(event) {
                this.db = event.target.result;
                resolve();
            }.bind(this);
            
            request.onerror = function(event) {
                reject(event.target.error);
            };
        });
    }

    getSettings() {
        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction(this.storeName, 'readonly');
            const store = transaction.objectStore(this.storeName);
            const request = store.get('settings');
            
            request.onsuccess = function() {
                resolve(request.result || {
                    currentFont: CONFIG.DEFAULT_FONT,
                    fontColor: CONFIG.DEFAULT_FONT_COLOR
                });
            };
            
            request.onerror = function() {
                reject(request.error);
            };
        });
    }

    saveSettings(data) {
        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction(this.storeName, 'readwrite');
            const store = transaction.objectStore(this.storeName);
            const request = store.put({
                id: 'settings',
                ...data
            });
            
            request.onsuccess = function() {
                resolve();
            };
            
            request.onerror = function() {
                reject(request.error);
            };
        });
    }

    getFonts() {
        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction(this.storeName, 'readonly');
            const store = transaction.objectStore(this.storeName);
            const request = store.getAll();
            
            request.onsuccess = function() {
                resolve(request.result.filter(item => item.id !== 'settings'));
            };
            
            request.onerror = function() {
                reject(request.error);
            };
        });
    }

    saveFont(fontData) {
        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction(this.storeName, 'readwrite');
            const store = transaction.objectStore(this.storeName);
            const request = store.add(fontData);
            
            request.onsuccess = function() {
                resolve();
            };
            
            request.onerror = function() {
                if (request.error.name === 'ConstraintError') {
                    const updateRequest = store.put(fontData);
                    updateRequest.onsuccess = function() {
                        resolve();
                    };
                    updateRequest.onerror = function() {
                        reject(updateRequest.error);
                    };
                } else {
                    reject(request.error);
                }
            };
        });
    }

    deleteFont(fontName) {
        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction(this.storeName, 'readwrite');
            const store = transaction.objectStore(this.storeName);
            const request = store.delete(fontName);
            
            request.onsuccess = function() {
                resolve();
            };
            
            request.onerror = function() {
                reject(request.error);
            };
        });
    }
}

// 在脚本管理器中显示“打开字体设置”选项
let fontManagerInstance = null; // 存储 FontManager 实例

function openFontSettings() {
    if (fontManagerInstance) {
        fontManagerInstance.togglePanel();
    }
}

// 注册菜单命令
GM_registerMenuCommand('打开字体设置', openFontSettings);

// 字体管理器
class FontManager {
    constructor() {
        this.dbManager = new DatabaseManager();
        this.state = {
            currentFont: CONFIG.DEFAULT_FONT,
            fontColor: CONFIG.DEFAULT_FONT_COLOR,
            localFonts: []
        };
        
        this.init().then(() => {
            this.loadFonts().then(() => {
                this.applyCurrentSettings();
                this.preloadFonts();
                this.createPanel();
                this.refreshPanel();
            });
        }).catch(error => {
            console.error('Initialization failed:', error);
        });
    }

    async init() {
        await this.dbManager.init();
    }

    createPanel() {
        this.panel = document.createElement('div');
        this.panel.id = 'via-font-panel';
        Object.assign(this.panel.style, {
            position: 'fixed',
            bottom: '0',
            left: '0',
            right: '0',
            background: 'rgba(255, 255, 255, 0.85)',
            backdropFilter: 'blur(15px)',
            borderRadius: '16px 16px 0 0',
            boxShadow: '0 -8px 20px rgba(0,0,0,0.12)',
            padding: '20px',
            transform: 'translateY(100%)',
            transition: 'transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
            maxHeight: '80vh',
            overflowY: 'auto',
            zIndex: 999998,
            WebkitBackdropFilter: 'blur(15px)',
            display: 'none'
        });
        this.panel.innerHTML = `
            <div class="header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; padding-bottom:16px; border-bottom:1px solid rgba(233, 236, 239, 0.5)">
                <h3 style="margin:0; font-family:'Segoe UI', 'Roboto', sans-serif; font-weight:600; color:#343a40">自定义字体管理</h3>
                <button class="close-btn" style="background:none; border:none; font-size:20px; cursor:pointer; color:#adb5bd; transition:color 0.2s">
                    <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                        <path d="M18 6L6 18M6 6L18 18" stroke="#adb5bd" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
                    </svg>
                </button>
            </div>
            <div class="content" style="padding-top:10px"></div>
        `;
        document.body.appendChild(this.panel);
    }

    togglePanel() {
        if (this.panel.style.display === 'none' || this.panel.style.display === '') {
            this.panel.style.display = 'block';
            this.panel.style.transform = 'translateY(0%)';
        } else {
            this.panel.style.transform = 'translateY(100%)';
            this.panel.style.display = 'none';
        }
    }

    refreshPanel() {
        const content = this.panel.querySelector('.content');
        content.innerHTML = `
            <div style="margin-bottom:20px; padding:15px; background:rgba(241, 245, 249, 0.7); border-radius:10px; border:1px solid rgba(224, 231, 255, 0.5); backdrop-filter:blur(5px)">
                <select class="font-select" style="width:100%; padding:10px 15px; border-radius:6px; border:1px solid rgba(209, 213, 219, 0.8); font-family:'Noto Sans SC', sans-serif; font-size:15px; box-shadow:inset 0 2px 4px rgba(0,0,0,0.05); transition:box-shadow 0.2s; background:rgba(255,255,255,0.9)">
                    <option value="system-ui">系统默认字体</option>
                    ${this.state.localFonts.map(font => `
                        <option value="${font.name}" ${this.state.currentFont === font.name ? 'selected' : ''}>
                            ${font.name}
                        </option>
                    `).join('')}
                </select>
            </div>

            <div style="margin-bottom:20px; padding:15px; background:rgba(241, 245, 249, 0.7); border-radius:10px; border:1px solid rgba(224, 232, 255, 0.5); backdrop-filter:blur(5px)">
                <label style="display:block; margin-bottom:10px; font-family:'Noto Sans SC', sans-serif; font-size:15px; color:#4a5568">字体颜色</label>
                <div style="display:flex; align-items:center; gap:15px">
                    <input type="color" id="font-color-picker" value="${this.state.fontColor}" style="width:40px; height:40px; border-radius:6px; border:none; cursor:pointer">
                    <div style="display:flex; flex-direction:column; gap:10px; flex-grow:1">
                        <input type="text" id="font-color-code" value="${this.state.fontColor}" placeholder="#RRGGBB" style="width:100%; padding:8px 12px; border-radius:6px; border:1px solid rgba(209, 213, 219, 0.8); font-family:'Noto Sans SC', sans-serif; font-size:14px; box-shadow:inset 0 2px 4px rgba(0,0,0,0.05); transition:box-shadow 0.2s; background:rgba(255,255,255,0.9)">
                        <button id="color-confirm-btn" style="width:100%; padding:8px 12px; background:#4299e1; color:white; border:none; border-radius:6px; font-family:'Noto Sans SC', sans-serif; font-size:14px; font-weight:500; cursor:pointer; transition:background 0.2s">
                            确认
                        </button>
                    </div>
                </div>
            </div>

            <div style="margin:20px 0; padding:15px; background:rgba(248, 249, 250, 0.7); border-radius:10px; border:1px solid rgba(226, 232, 240, 0.5); backdrop-filter:blur(5px)">
                <label style="display:block; margin-bottom:10px; font-family:'Noto Sans SC', sans-serif; font-size:15px; color:#4a5568">批量上传字体文件(支持 ${Object.keys(CONFIG.FONT_TYPES).map(e => `.${e}`).join(', ')})</label>
                <input type="file" accept="${Object.keys(CONFIG.FONT_TYPES).map(e => `.${e}`).join(',')}" 
                       multiple style="width:100%; padding:10px 15px; border-radius:6px; border:2px dashed rgba(160, 174, 192, 0.5); font-family:'Noto Sans SC', sans-serif; cursor:pointer; transition:border 0.2s; background:rgba(255,255,255,0.7)">
            </div>

            <div class="font-list" style="margin-top:20px">
                <h4 style="margin-bottom:10px; font-family:'Noto Sans SC', sans-serif; font-size:17px; color:#2d3748; font-weight:600; background:rgba(241, 245, 249, 0.5); padding:8px 15px; border-radius:8px; backdrop-filter:blur(5px)">已安装字体 (${this.state.localFonts.length})</h4>
                <ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns:repeat(auto-fill, minmax(180px, 1fr)); gap:15px">
                    ${this.state.localFonts.map(font => `
                        <li style="background:rgba(241, 245, 249, 0.7); padding:15px; border-radius:8px; display:flex; flex-direction:column; gap:10px; border:1px solid rgba(226, 232, 240, 0.5); box-shadow:0 2px 4px rgba(0,0,0,0.05); transition:transform 0.2s, box-shadow 0.2s; backdrop-filter:blur(5px)">
                            <span style="font-family:'Noto Sans SC', sans-serif; font-size:15px; color:#4a5568">${font.name}</span>
                            <button data-font="${font.name}" class="delete-btn" 
                                    style="padding:6px 12px; background:#ef233c; color:white; border:none; border-radius:5px; font-family:'Noto Sans SC', sans-serif; font-size:14px; font-weight:500; cursor:pointer; transition:background 0.2s; justify-self:flex-end">
                                删除
                            </button>
                        </li>
                    `).join('')}
                </ul>
            </div>
        `;

        this.setupPanelEventListeners();
    }

    setupPanelEventListeners() {
        this.panel.querySelector('.font-select').addEventListener('change', e => {
            this.applyFont(e.target.value);
        });

        this.panel.querySelector('#font-color-picker').addEventListener('input', e => {
            this.state.fontColor = e.target.value;
            document.documentElement.style.setProperty('--global-font-color', this.state.fontColor);
            this.panel.querySelector('#font-color-code').value = this.state.fontColor;
            this.saveSettings();
        });

        this.panel.querySelector('#font-color-code').addEventListener('input', e => {
            if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) {
                this.state.fontColor = e.target.value;
                document.documentElement.style.setProperty('--global-font-color', this.state.fontColor);
                this.panel.querySelector('#font-color-picker').value = this.state.fontColor;
            }
        });

        this.panel.querySelector('#color-confirm-btn').addEventListener('click', () => {
            const colorCode = this.panel.querySelector('#font-color-code').value;
            if (/^#[0-9A-Fa-f]{6}$/.test(colorCode)) {
                this.state.fontColor = colorCode;
                document.documentElement.style.setProperty('--global-font-color', this.state.fontColor);
                this.panel.querySelector('#font-color-picker').value = this.state.fontColor;
                this.saveSettings();
            } else {
                alert('请输入有效的颜色代码(如 #FF5733)');
            }
        });

        this.panel.querySelector('input[type="file"]').addEventListener('change', async e => {
            await this.handleFiles(Array.from(e.target.files));
            this.refreshPanel();
        });

        this.panel.querySelectorAll('.delete-btn').forEach(btn => {
            btn.addEventListener('click', () => {
                this.deleteFont(btn.dataset.font);
            });
        });

        this.panel.querySelector('.close-btn').addEventListener('click', () => {
            this.togglePanel();
        });

        // 添加全局样式
        const style = document.createElement('style');
        style.textContent = `
            :root {
                --primary-color: #4299e1;
                --primary-dark: #3182ce;
                --text-color: #2d3748;
                --bg-color: #f8f9fa;
                --border-color: #e2e8f0;
                --global-font-color: ${this.state.fontColor};
            }

            body *:not(input):not(textarea) {
                color: var(--global-font-color) !important;
            }

            .font-select:focus {
                outline: none;
                box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.3);
                border-color: var(--primary-dark);
            }

            input[type="file"]:hover {
                border-color: rgba(160, 174, 192, 0.8);
                background: rgba(241, 245, 249, 0.8);
            }

            input[type="file"]:active {
                border-color: rgba(73, 85, 102, 0.8);
                background: rgba(226, 232, 240, 0.8);
            }

            .delete-btn:hover {
                background: #e53e3e;
            }

            .close-btn:hover svg {
                color: rgba(113, 128, 150, 0.8);
            }

            .panel {
                overflow-y: auto !important;
            }

            #font-color-picker::-webkit-color-swatch-wrapper {
                padding: 0;
            }

            #font-color-picker::-webkit-color-swatch {
                border: none;
                border-radius: 6px;
            }

            #color-confirm-btn:hover {
                background: #3182ce;
            }

            ::-webkit-scrollbar {
                width: 8px;
            }

            ::-webkit-scrollbar-track {
                background: rgba(226, 232, 240, 0.3);
                border-radius: 4px;
            }

            ::-webkit-scrollbar-thumb {
                background: rgba(160, 174, 192, 0.5);
                border-radius: 4px;
            }

            ::-webkit-scrollbar-thumb:hover {
                background: rgba(160, 174, 192, 0.8);
            }

            body {
                --webkit-backdrop-filter: blur(15px);
            }
        `;
        document.head.appendChild(style);
    }

    async handleFiles(files) {
        for (const file of files) {
            await this.handleFontFile(file);
        }
    }

    async handleFontFile(file) {
        const ext = file.name.split('.').pop().toLowerCase();
        if (!CONFIG.FONT_TYPES[ext]) {
            alert(`不支持的文件类型: ${ext}`);
            return;
        }

        const fontName = prompt('请输入字体名称:', file.name.replace(/\.[^.]+$/, ''));
        if (!fontName) return;

        let fontExists = this.state.localFonts.some(font => font.name === fontName);
        if (fontExists && !confirm('字体已存在,是否覆盖?')) return;

        const dataURL = await this.readFileAsDataURL(file);
        const fontData = {
            id: fontName,
            name: fontName,
            data: dataURL,
            format: CONFIG.FONT_TYPES[ext].format,
            date: new Date().toISOString()
        };

        await this.dbManager.saveFont(fontData);
        
        // 更新状态和UI
        this.state.localFonts = await this.dbManager.getFonts();
        if (fontExists) {
            this.applyFont(fontName);
        }
        
        this.refreshPanel();
    }

    readFileAsDataURL(file) {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = () => resolve(reader.result);
            reader.onerror = reject;
            reader.readAsDataURL(file);
        });
    }

    async deleteFont(fontName) {
        if (!confirm(`确定删除字体 "${fontName}" 吗?`)) return;
        
        await this.dbManager.deleteFont(fontName);
        
        // 更新状态和UI
        this.state.localFonts = await this.dbManager.getFonts();
        if (this.state.currentFont === fontName) {
            this.applyFont(CONFIG.DEFAULT_FONT);
        }
        
        this.refreshPanel();
    }

    async applyFont(fontName) {
        // 移除旧的字体样式
        document.querySelectorAll('style[data-custom-font]').forEach(e => e.remove());

        // 应用新的字体
        if (fontName !== CONFIG.DEFAULT_FONT) {
            const font = this.state.localFonts.find(f => f.name === fontName);
            if (font) {
                const style = document.createElement('style');
                style.dataset.customFont = fontName;
                style.textContent = `
                    @font-face {
                        font-family: "${fontName}";
                        src: url(${font.data}) format("${font.format}");
                        font-display: swap;
                    }
                    body *:not(input):not(textarea) {
                        font-family: "${fontName}" !important;
                    }
                `;
                document.head.appendChild(style);
            }
        }

        this.state.currentFont = fontName;
        await this.saveSettings();
        this.panel.querySelectorAll('.font-select option').forEach(option => {
            if (option.value === fontName) {
                option.selected = true;
            }
        });
    }

    async loadFonts() {
        try {
            const settings = await this.dbManager.getSettings();
            this.state.currentFont = settings.currentFont || CONFIG.DEFAULT_FONT;
            this.state.fontColor = settings.fontColor || CONFIG.DEFAULT_FONT_COLOR;
        } catch (error) {
            console.error('Failed to load settings:', error);
        }
        
        try {
            this.state.localFonts = await this.dbManager.getFonts();
        } catch (error) {
            console.error('Failed to load fonts:', error);
        }
    }

    async saveSettings() {
        await this.dbManager.saveSettings({
            currentFont: this.state.currentFont,
            fontColor: this.state.fontColor
        });
    }

    async applyCurrentSettings() {
        await this.applyFont(this.state.currentFont);
        document.documentElement.style.setProperty('--global-font-color', this.state.fontColor);
        document.querySelectorAll('body *:not(input):not(textarea)').forEach(el => {
            el.style.color = this.state.fontColor;
        });
    }

    preloadFonts() {
        this.state.localFonts.forEach(font => {
            const link = document.createElement('link');
            link.rel = 'preload';
            link.as = 'font';
            link.type = `font/${font.format}`;
            link.crossOrigin = 'anonymous';
            link.href = font.data;
            link.onload = () => {
                console.log(`Font ${font.name} preloaded`);
            };
            link.onerror = () => {
                console.error(`Failed to preload font ${font.name}`);
            };
            document.head.appendChild(link);
        });
    }
}

// 初始化 FontManager
document.addEventListener('DOMContentLoaded', () => {
    fontManagerInstance = new FontManager();
});