移动端网页字体修改器

支持字体管理和拖动按钮的移动端字体工具

目前為 2025-05-24 提交的版本,檢視 最新版本

// ==UserScript==
// @name         移动端网页字体修改器
// @namespace    http://via-browser.com/
// @version      1.4
// @description  支持字体管理和拖动按钮的移动端字体工具
// @author       ^_^
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // 配置参数
    const CONFIG = {
        MAX_FILE_SIZE: 5 * 1024 * 1024,   // 5MB
        DEFAULT_FONT: 'system-ui',
        STORAGE_KEY: 'VIA_FONT_SETTINGS',
        FONT_TYPES: {
            'ttf':  { format: 'truetype' },
            'otf':  { format: 'opentype' },
            'woff': { format: 'woff' },
            'woff2':{ format: 'woff2' }
        }
    };

    // 存储管理
    const storage = {
        get() {
            return JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEY)) || {
                currentFont: CONFIG.DEFAULT_FONT,
                localFonts: {},
                enabled: true,
                fabPosition: null
            };
        },
        save(data) {
            localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(data));
        }
    };

    // 移动端UI组件
    class MobileUI {
        static createFAB() {
            const state = storage.get();
            const fab = document.createElement('div');
            fab.id = 'via-font-fab';
            fab.innerHTML = 'A';
            
            Object.assign(fab.style, {
                position: 'fixed',
                width: '50px',
                height: '50px',
                background: '#2196F3',
                color: 'white',
                borderRadius: '50%',
                textAlign: 'center',
                lineHeight: '50px',
                fontSize: '24px',
                boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
                zIndex: 999999,
                touchAction: 'none',
                userSelect: 'none',
                left: state.fabPosition ? `${state.fabPosition.x}px` : 'auto',
                top: state.fabPosition ? `${state.fabPosition.y}px` : 'auto',
                right: state.fabPosition ? 'auto' : '20px',
                bottom: state.fabPosition ? 'auto' : '30px',
                transition: 'left 0.2s, top 0.2s'
            });

            document.body.appendChild(fab);
            return fab;
        }

        static createPanel() {
            const panel = document.createElement('div');
            panel.id = 'via-font-panel';
            Object.assign(panel.style, {
                position: 'fixed',
                bottom: '0',
                left: '0',
                right: '0',
                background: 'white',
                padding: '16px',
                boxShadow: '0 -4px 10px rgba(0,0,0,0.1)',
                transform: 'translateY(100%)',
                transition: 'transform 0.3s ease',
                maxHeight: '70vh',
                overflowY: 'auto',
                zIndex: 999998
            });
            panel.innerHTML = `
                <div class="header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px">
                    <h3 style="margin:0">字体设置</h3>
                    <button class="close-btn" style="background:none; border:none; font-size:24px">×</button>
                </div>
                <div class="content"></div>
            `;
            document.body.appendChild(panel);
            return panel;
        }
    }

    // 字体管理器
    class FontManager {
        constructor() {
            this.state = storage.get();
            this.isDragging = false;
            this.initUI();
            this.setupDrag();
            this.applyCurrentFont();
        }

        initUI() {
            this.fab = MobileUI.createFAB();
            this.panel = MobileUI.createPanel();

            // 关闭按钮事件
            this.panel.querySelector('.close-btn').addEventListener('click', () => {
                this.hidePanel();
            });

            // 悬浮按钮点击事件
            this.fab.addEventListener('click', (e) => {
                if (this.isDragging) {
                    e.preventDefault();
                    e.stopPropagation();
                    this.isDragging = false;
                    return;
                }
                this.togglePanel();
            });
        }

        setupDrag() {
            let startX, startY, initialX, initialY;
            
            const onTouchStart = (e) => {
                this.isDragging = false;
                const touch = e.touches[0];
                startX = touch.clientX;
                startY = touch.clientY;
                initialX = this.fab.offsetLeft;
                initialY = this.fab.offsetTop;
                this.fab.style.transition = 'none';
                
                document.addEventListener('touchmove', onTouchMove);
                document.addEventListener('touchend', onTouchEnd);
            };

            const onTouchMove = (e) => {
                if (!e.touches.length) return;
                const touch = e.touches[0];
                const deltaX = touch.clientX - startX;
                const deltaY = touch.clientY - startY;
                
                if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
                    this.isDragging = true;
                }

                let newX = initialX + deltaX;
                let newY = initialY + deltaY;

                const maxX = window.innerWidth - this.fab.offsetWidth;
                const maxY = window.innerHeight - this.fab.offsetHeight;
                newX = Math.max(0, Math.min(newX, maxX));
                newY = Math.max(0, Math.min(newY, maxY));

                this.fab.style.left = `${newX}px`;
                this.fab.style.top = `${newY}px`;
                this.fab.style.right = 'auto';
                this.fab.style.bottom = 'auto';
            };

            const onTouchEnd = () => {
                document.removeEventListener('touchmove', onTouchMove);
                document.removeEventListener('touchend', onTouchEnd);
                this.fab.style.transition = 'left 0.2s, top 0.2s';

                if (this.isDragging) {
                    this.state.fabPosition = {
                        x: this.fab.offsetLeft,
                        y: this.fab.offsetTop
                    };
                    storage.save(this.state);
                }
            };

            this.fab.addEventListener('touchstart', onTouchStart);
        }

        togglePanel() {
            const isOpen = this.panel.style.transform === 'translateY(0%)';
            this.panel.style.transform = isOpen ? 'translateY(100%)' : 'translateY(0%)';
            if (!isOpen) this.refreshPanel();
        }

        refreshPanel() {
            const content = this.panel.querySelector('.content');
            content.innerHTML = `
                <div style="margin-bottom:16px">
                    <select class="font-select" style="width:100%; padding:8px; border-radius:4px">
                        <option value="system-ui">系统默认</option>
                        ${Object.keys(this.state.localFonts).map(name => `
                            <option value="${name}" ${this.state.currentFont === name ? 'selected' : ''}>
                                ${name}
                            </option>
                        `).join('')}
                    </select>
                </div>

                <div style="margin:16px 0">
                    <label style="display:block; margin-bottom:8px">批量上传字体文件:</label>
                    <input type="file" accept="${Object.keys(CONFIG.FONT_TYPES).map(e => `.${e}`).join(',')}" 
                           multiple style="width:100%" id="font-upload">
                </div>

                <div style="margin:16px 0">
                    <label style="display:flex; align-items:center; gap:8px">
                        <input type="checkbox" ${this.state.enabled ? 'checked' : ''}>
                        启用字体替换
                    </label>
                </div>

                <div class="font-list">
                    <h4 style="margin:8px 0">已安装字体 (${Object.keys(this.state.localFonts).length})</h4>
                    <ul style="list-style:none; padding:0; margin:0">
                        ${Object.entries(this.state.localFonts).map(([name, font]) => `
                            <li style="padding:12px 0; border-bottom:1px solid #eee; display:flex; justify-content:space-between; align-items:center">
                                <span>${name}</span>
                                <button data-font="${name}" class="delete-btn" 
                                        style="padding:4px 12px; background:#ff4444; color:white; border:none; border-radius:4px">
                                    删除
                                </button>
                            </li>
                        `).join('')}
                    </ul>
                </div>
            `;

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

            content.querySelector('input[type="checkbox"]').addEventListener('change', e => {
                this.toggleFeature(e.target.checked);
            });

            content.querySelector('#font-upload').addEventListener('change', async e => {
                await this.processFiles(Array.from(e.target.files));
                this.refreshPanel();
            });

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

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

        async handleFontFile(file) {
            if (file.size > CONFIG.MAX_FILE_SIZE) {
                alert(`文件大小超过限制 (最大${CONFIG.MAX_FILE_SIZE/1024/1024}MB)`);
                return;
            }

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

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

            if (this.state.localFonts[fontName] && !confirm('字体已存在,是否覆盖?')) return;

            const dataURL = await this.readFileAsDataURL(file);
            this.state.localFonts[fontName] = {
                name: fontName,
                data: dataURL,
                format: CONFIG.FONT_TYPES[ext].format
            };
            storage.save(this.state);
        }

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

        deleteFont(fontName) {
            if (!confirm(`确定删除字体 "${fontName}" 吗?`)) return;
            
            delete this.state.localFonts[fontName];
            if (this.state.currentFont === fontName) {
                this.applyFont(CONFIG.DEFAULT_FONT);
            }
            storage.save(this.state);
            this.refreshPanel();
        }

        applyFont(fontName) {
            document.querySelectorAll('style[data-custom-font]').forEach(e => e.remove());

            if (fontName !== CONFIG.DEFAULT_FONT) {
                const fontData = this.state.localFonts[fontName];
                if (fontData) {
                    const style = document.createElement('style');
                    style.dataset.customFont = fontName;
                    style.textContent = `
                        @font-face {
                            font-family: "${fontName}";
                            src: url(${fontData.data}) format("${fontData.format}");
                            font-display: swap;
                        }
                        body *:not(input):not(textarea) {
                            font-family: "${fontName}" !important;
                        }
                    `;
                    document.head.appendChild(style);

                    // 字体加载检测
                    const checkFont = () => {
                        if (document.fonts.check(`12px "${fontName}"`)) {
                            document.body.style.fontFamily = `${fontName}, sans-serif`;
                        } else {
                            document.fonts.load(`12px "${fontName}"`).then(checkFont);
                        }
                    };
                    checkFont();
                }
            }
            
            this.state.currentFont = fontName;
            storage.save(this.state);
        }

        toggleFeature(enabled) {
            this.state.enabled = enabled;
            storage.save(this.state);
            document.body.style.fontFamily = enabled ? 
                `${this.state.currentFont}, sans-serif` : 
                'inherit';
        }

        hidePanel() {
            this.panel.style.transform = 'translateY(100%)';
        }

        applyCurrentFont() {
            if (this.state.enabled) {
                this.applyFont(this.state.currentFont);
            }
        }
    }

    // 初始化
    setTimeout(() => new FontManager(), 1000);

})();