Greasy Fork

网页字体修改器

导入本地字体更换网页字体,支持悬浮球控制和自定义字体颜色

// ==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
// ==/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 = `
        <div class="panel-header">
          <h3 class="panel-title">字体设置</h3>
          <button class="close-btn">×</button>
        </div>
        <div class="panel-content"></div>
      `;
      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 = `
        <div class="setting-group">
          <label class="setting-label">当前字体</label>
          <select class="font-select">
            ${fontData.fonts.map(font => `<option value="${font.name}" ${fontData.currentFont === font.name ? 'selected' : ''}>${font.name}</option>`).join('')}
          </select>
        </div>
        <div class="setting-group">
          <label class="setting-label">字体颜色</label>
          <div class="color-controls">
            <input type="color" class="color-picker" value="${fontData.fontColor}">
            <input type="text" class="color-input" value="${fontData.fontColor}">
            <button class="color-reset-btn">重置</button>
          </div>
        </div>
        <div class="upload-area">
          <label class="upload-btn">选择字体</label>
          <input type="file" class="upload-input" accept=".ttf,.otf,.woff,.woff2" multiple>
        </div>
        <div class="setting-group">
          <h4 class="installed-fonts-title">已安装字体 (${fontData.fonts.length})</h4>
          <div class="fonts-grid">
            ${fontData.fonts.filter(f => !f.isDefault).map(font => `<div class="font-item"><div class="font-name">${font.name}</div><button data-font="${font.name}" class="delete-btn">删除</button></div>`).join('')}
          </div>
        </div>
      `;
      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();
  }
})();