barretlee / QuoteSnap

// ==UserScript==
// @name         QuoteSnap
// @namespace    https://openuserjs.org/users/barretlee
// @require      https://cdn.bootcss.com/html2canvas/0.5.0-beta4/html2canvas.js
// @version      1.5
// @description  在 CSP 严格环境下生成划词卡片,可下载或复制,带磨砂背景、优化间距、精美交互、随机主题切换。
// @author       Barret Lee <barret.china@gmail.com>
// @match        *://*/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
  'use strict';

  const COLOR_PRESETS = [
    { bg: 'rgba(30,30,30,0.65)', text: '#fff' },
    { bg: 'rgba(255,255,255,0.75)', text: '#000' },
    { bg: 'rgba(0,24,88,0.55)', text: '#fef6e4' },
    { bg: 'rgba(224,222,244,0.7)', text: '#232136' },
    { bg: 'rgba(240,100,80,0.65)', text: '#fff' },
    { bg: 'rgba(44,120,180,0.6)', text: '#fff' },
    { bg: 'rgba(200,255,200,0.6)', text: '#232136' },
  ];

  let buffer = '';
  let timer = null;
  let selectedText = '';
  let panel = null, preview = null;
  let currentTheme = COLOR_PRESETS[0];

  function createPanel() {
    const wrapper = document.createElement('div');
    wrapper.id = 'share-card-panel';
    wrapper.style.cssText = `
      position: fixed;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      background: rgba(255,255,255,0.1);
      border-radius: 18px;
      box-shadow: 0 6px 32px rgba(0,0,0,0.25);
      width: 420px;
      max-width: 90vw;
      backdrop-filter: blur(10px);
      z-index: 999999;
      font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
      overflow: hidden;
      animation: fadeIn 0.25s ease;
    `;

    wrapper.innerHTML = `
      <style>
        @keyframes fadeIn {
          from { opacity: 0; transform: translate(-50%, -45%);}
          to { opacity: 1; transform: translate(-50%, -50%);}
        }
        #share-card-preview {
          padding: 28px 24px 58px 24px;
          min-height: 200px;
          border-radius: 14px;
          transition: all 0.3s ease;
          position: relative;
          line-height: 1.8;
          -webkit-font-smoothing: antialiased;
          -moz-osx-font-smoothing: grayscale;
          text-rendering: geometricPrecision;
        }
        #share-card-domain {
          position: absolute;
          bottom: 30px;
          right: 20px;
          font-size: 13px;
          opacity: 0.75;
          font-weight: 600;
          letter-spacing: 0.5px;
        }
        #share-card-text {
          font-size: 17px;
          margin: 8px;
          white-space: normal;
          word-break: break-word;
          text-align: justify;
          text-justify: inter-word;
        }
        #share-card-close {
          position: absolute;
          top: 10px;
          right: 10px;
          width: 28px;
          height: 28px;
          line-height: 26px;
          text-align: center;
          font-size: 16px;
          border-radius: 50%;
          background: rgba(255,255,255,0.15);
          cursor: pointer;
          color: inherit;
          transition: all 0.2s;
        }
        #share-card-close:hover {
          background: rgba(255,255,255,0.3);
        }
        #share-card-footer {
          display: flex;
          justify-content: flex-end;
          gap: 10px;
          padding: 12px 18px;
          border-top: 1px solid rgba(255,255,255,0.15);
        }
        #share-card-footer button {
          padding: 6px 10px;
          border: none;
          border-radius: 6px;
          background: rgba(255,255,255,0.2);
          color: inherit;
          cursor: pointer;
          font-size: 13px;
          transition: all 0.2s ease;
        }
        #share-card-footer button:hover {
          background: rgba(255,255,255,0.35);
        }
        #share-card-colors {
          display: flex;
          justify-content: space-around;
          padding: 10px 0 16px 0;
          border-top: 1px solid rgba(255,255,255,0.15);
          background: rgba(255,255,255,0.05);
        }
        #share-card-colors div {
          width: 28px;
          height: 28px;
          border-radius: 50%;
          cursor: pointer;
          transition: all 0.25s ease;
          box-shadow: 0 0 6px rgba(0,0,0,0.2);
        }
        #share-card-colors div:hover {
          transform: scale(1.2);
          box-shadow: 0 0 10px rgba(0,0,0,0.3);
        }
        #share-card-colors div.active {
          outline: 2px solid #fff;
          box-shadow: 0 0 0 2px rgba(255,255,255,0.4);
        }
      </style>
      <div id="share-card-preview">
        <div id="share-card-close">×</div>
        <div id="share-card-text" contenteditable></div>
        <div id="share-card-domain" contenteditable>${location.hostname}</div>
      </div>
      <div id="share-card-footer">
        <button id="btn-random">🎨 随机</button>
        <button id="btn-copy">📋 复制</button>
        <button id="btn-save">💾 下载</button>
      </div>
      <div id="share-card-colors"></div>
    `;

    document.body.appendChild(wrapper);
    panel = wrapper;
    preview = wrapper.querySelector('#share-card-preview');
    wrapper.querySelector('#share-card-text').textContent = selectedText;
    wrapper.querySelector('#share-card-close').onclick = () => wrapper.remove();

    // ESC 快捷键关闭
    document.addEventListener('keydown', e => {
      if (e.key === 'Escape' && panel) panel.remove();
    });

    // 颜色按钮
    const colors = wrapper.querySelector('#share-card-colors');
    COLOR_PRESETS.forEach(({ bg, text }, idx) => {
      const div = document.createElement('div');
      div.style.background = bg;
      div.style.border = `2px solid ${text}`;
      div.title = `主题 ${idx + 1}`;
      div.onclick = () => updateColor(bg, text, idx);
      colors.appendChild(div);
    });

    // 初始化默认主题
    updateColor(COLOR_PRESETS[0].bg, COLOR_PRESETS[0].text);

    wrapper.querySelector('#btn-save').onclick = downloadCard;
    wrapper.querySelector('#btn-copy').onclick = copyCard;
    wrapper.querySelector('#btn-random').onclick = randomTheme;
  }

  function updateColor(bg, text, idx = 0) {
    currentTheme = COLOR_PRESETS[idx];
    preview.style.background = bg;
    preview.style.color = text;
    preview.style.backdropFilter = 'blur(12px)';
    document.querySelectorAll('#share-card-colors div').forEach((el, i) => {
      el.classList.toggle('active', i === idx);
    });
  }

  function randomTheme() {
    const idx = Math.floor(Math.random() * COLOR_PRESETS.length);
    const { bg, text } = COLOR_PRESETS[idx];
    updateColor(bg, text, idx);
  }

  function generateCanvas(callback) {
    const closeBtn = preview.querySelector('#share-card-close');
    const origDisplay = closeBtn.style.display;
    const origBg = preview.style.background;

    closeBtn.style.display = 'none';
    preview.style.background = currentTheme.bg;

    html2canvas(preview, {
      scale: 2,
      useCORS: true,
      backgroundColor: null,
      width: preview.offsetWidth,
      height: preview.offsetHeight,
      allowTaint: true,
      foreignObjectRendering: false,
    }).then(canvas => {
      // 去毛边
      const ctx = canvas.getContext('2d');
      ctx.imageSmoothingEnabled = false;

      // ⬇️ 新增放大两倍的逻辑
      const scaledCanvas = document.createElement('canvas');
      scaledCanvas.width = canvas.width * 2;
      scaledCanvas.height = canvas.height * 2;
      const scaledCtx = scaledCanvas.getContext('2d');
      scaledCtx.imageSmoothingEnabled = false;
      scaledCtx.scale(2, 2);
      scaledCtx.drawImage(canvas, 0, 0);

      closeBtn.style.display = origDisplay;
      preview.style.background = origBg;

      callback(scaledCanvas);
    }).catch(err => {
      closeBtn.style.display = origDisplay;
      preview.style.background = origBg;
      console.error('生成卡片失败:', err);
    });
  }

  function downloadCard() {
    generateCanvas(canvas => {
      const link = document.createElement('a');
      link.download = `share-card-${Date.now()}.png`;
      link.href = canvas.toDataURL('image/png');
      link.click();
    });
  }

  function copyCard() {
    generateCanvas(canvas => {
      canvas.toBlob(async blob => {
        try {
          await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
          alert('✅ 图片已复制到剪贴板');
        } catch {
          alert('⚠️ 无法复制,请改为下载保存');
        }
      });
    });
  }

  document.addEventListener('keydown', e => {
    if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return;
    buffer += e.key.toLowerCase();
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => (buffer = ''), 800);
    if (buffer.endsWith('card') || buffer.endsWith('cccc')) {
      const sel = window.getSelection();
      if (sel && sel.toString().trim()) {
        selectedText = sel.toString().trim();
        showPanel();
      }
      buffer = '';
    }
  });

  function showPanel() {
    if (panel) panel.remove();
    createPanel();
  }
})();