// ==UserScript== // @name 智能剪切板 // @namespace https://scriptcat.org/smart-clipboard // @version 2.3.0 // @description 生产级网页剪贴板工具:复制时主动捕获选中文本/输入框内容,解决必须点击读取的问题;支持图片手动读、智能分类、7天/50条/容量维护。 // @author ChatGPT // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij4KPGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agc3RvcC1jb2xvcj0iIzdjM2FlZCIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzA2YjZkNCIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPgo8cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgcng9IjMwIiBmaWxsPSJ1cmwoI2cpIi8+CjxwYXRoIGQ9Ik00NCAzMmg0MGE4IDggMCAwIDEgOCA4djU2YTggOCAwIDAgMS04IDhINDRhOCA4IDAgMCAxLTgtOFY0MGE4IDggMCAwIDEgOC04eiIgZmlsbD0id2hpdGUiIG9wYWNpdHk9Ii45MiIvPgo8cmVjdCB4PSI0OCIgeT0iMjIiIHdpZHRoPSIzMiIgaGVpZ2h0PSIyMiIgcng9IjkiIGZpbGw9IiMwZjE3MmEiLz4KPHBhdGggZD0iTTUyIDYybDE0IDE0IDI0LTMwIiBmaWxsPSJub25lIiBzdHJva2U9IiM3YzNhZWQiIHN0cm9rZS13aWR0aD0iMTAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4= // @match *://*/* // @run-at document-start // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_setClipboard // @grant GM_registerMenuCommand // @grant GM_addStyle // @connect * // @license MIT // ==/UserScript== (function () { 'use strict'; const APP = { name: '智能剪切板', version: '2.3.0', storageKey: 'smart_clipboard_records_v23', oldKeys: ['smart_clipboard_records_v22', 'smart_clipboard_records_v21', 'smart_clipboard_records_v2'], settingsKey: 'smart_clipboard_settings_v23', oldSettingKeys: ['smart_clipboard_settings_v22', 'smart_clipboard_settings_v21', 'smart_clipboard_settings_v2'], rulesKey: 'smart_clipboard_rules_v23', oldRulesKeys: ['smart_clipboard_rules_v22', 'smart_clipboard_rules_v21'], maxImageBytes: 2.8 * 1024 * 1024, hotkey: 'Alt+Shift+C' }; const defaultSettings = { autoCapture: true, captureKeyboardCopy: true, captureCopyEvent: true, captureSelectionOnMouse: false, autoReadClipboardAfterCopy: false, saveImages: true, compressImages: true, imageMaxWidth: 1280, imageQuality: 0.82, maxRecords: 50, maxDays: 7, maxStorageMB: 8, theme: 'dark', floatTop: 260, privacyMode: true, skipSecrets: true, updateDuplicateTime: true, duplicateWindowMinutes: 1440, maintainOnStart: true, showCaptureToast: false }; const defaultRules = [ { name: '财务', pattern: '发票|报销|invoice|receipt|税|金额|付款|收款', type: '财务' }, { name: '待办', pattern: 'TODO|待办|记得|提醒|deadline|ddl', type: '待办' }, { name: '账号', pattern: '登录|账号|用户名|user(name)?', type: '账号' } ]; let state = { records: [], settings: loadSettings(), rules: loadRules(), root: null, panel: null, visible: false, activeType: '全部', keyword: '', lastFingerprint: '', selectedId: null, selectedIds: new Set(), lastCaptureAt: 0, recentSelectionText: '', recentSelectionAt: 0 }; function nowText() { const d = new Date(); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } function uid() { return 'sc_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 9); } function safeGMGet(key, fallback) { try { const v = GM_getValue(key); return v === undefined || v === null ? fallback : v; } catch (e) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : fallback; } catch (_) { return fallback; } } } function safeGMSet(key, value) { try { GM_setValue(key, value); } catch (e) { localStorage.setItem(key, JSON.stringify(value)); } } function firstExisting(keys, fallback) { for (const key of keys) { const v = safeGMGet(key, undefined); if (v !== undefined) return v; } return fallback; } function loadSettings() { return Object.assign({}, defaultSettings, firstExisting(APP.oldSettingKeys, {}), safeGMGet(APP.settingsKey, {})); } function saveSettings() { safeGMSet(APP.settingsKey, state.settings); } function loadRules() { let rules = safeGMGet(APP.rulesKey, undefined); if (!rules) rules = firstExisting(APP.oldRulesKeys, defaultRules); return Array.isArray(rules) ? rules : defaultRules; } function saveRules() { safeGMSet(APP.rulesKey, state.rules); } function loadRecords() { const data = safeGMGet(APP.storageKey, firstExisting(APP.oldKeys, [])); state.records = Array.isArray(data) ? data : []; if (state.settings.maintainOnStart) { maintainRecords(false); safeGMSet(APP.storageKey, state.records); } } function saveRecords() { maintainRecords(false); safeGMSet(APP.storageKey, state.records); } function estimateStorageBytes(records = state.records) { try { return new Blob([JSON.stringify(records)]).size; } catch (_) { return JSON.stringify(records).length * 2; } } function parseRecordTime(r) { const s = r.updatedAt || r.createdAt || ''; const t = Date.parse(String(s).replace(/-/g, '/')); return Number.isFinite(t) ? t : 0; } function maintainRecords(showToast) { const maxRecords = Math.max(1, Number(state.settings.maxRecords || 50)); const maxDays = Math.max(1, Number(state.settings.maxDays || 7)); const maxBytes = Math.max(1, Number(state.settings.maxStorageMB || 8)) * 1024 * 1024; const expire = Date.now() - maxDays * 86400000; let removedByDays = 0, removedByCount = 0, removedBySize = 0; state.records = state.records.filter(r => { if (r.favorite) return true; const t = parseRecordTime(r); const keep = !t || t >= expire; if (!keep) removedByDays++; return keep; }); if (state.records.length > maxRecords) { const favorites = state.records.filter(r => r.favorite); const normal = state.records.filter(r => !r.favorite); const keepNormalCount = Math.max(0, maxRecords - favorites.length); removedByCount = Math.max(0, normal.length - keepNormalCount); state.records = [...favorites, ...normal.slice(0, keepNormalCount)].sort((a, b) => parseRecordTime(b) - parseRecordTime(a)); } while (estimateStorageBytes(state.records) > maxBytes) { const idx = findOldestNonFavoriteIndex(); if (idx < 0) break; state.records.splice(idx, 1); removedBySize++; } if (showToast) toast(`维护完成:过期${removedByDays},超条数${removedByCount},超容量${removedBySize}`); } function findOldestNonFavoriteIndex() { let idx = -1, min = Infinity; state.records.forEach((r, i) => { if (r.favorite) return; const t = parseRecordTime(r) || 0; if (t < min) { min = t; idx = i; } }); return idx; } function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function looksSecret(text) { const t = String(text || '').trim(); if (!t) return false; const secretPatterns = [ /(?:password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key|authorization|bearer)\s*[:=]\s*['"]?[A-Za-z0-9_\-\.={}\/+]{8,}/i, /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/, /\bsk-[A-Za-z0-9]{20,}\b/, /\b\d{6}\b/, /\b\d{13,19}\b/, /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/ ]; return secretPatterns.some(re => re.test(t)); } function maskSecret(text) { let t = String(text || ''); t = t.replace(/((?:password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key|authorization|bearer)\s*[:=]\s*['"]?)([^\s'"]{6,})/ig, '$1••••••••'); t = t.replace(/\b\d{13,19}\b/g, m => m.slice(0, 4) + ' **** **** ' + m.slice(-4)); t = t.replace(/\b\d{6}\b/g, '••••••'); return t; } function classifyText(text) { const t = String(text || '').trim(); for (const rule of state.rules || []) { try { if (rule.pattern && new RegExp(rule.pattern, 'i').test(t)) return rule.type || rule.name || '自定义'; } catch (_) {} } if (!t) return '文本'; if (looksSecret(t)) return '隐私'; if (/^https?:\/\/[^\s]+$/i.test(t)) return '链接'; if (/^mailto:/i.test(t) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) return '邮箱'; if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) { try { JSON.parse(t); return 'JSON'; } catch (_) {} } if (/^\s*(function|const|let|var|class|import|export|async|def |class |public |private |#include|package |SELECT |INSERT |UPDATE |DELETE |CREATE |<\?php|using namespace)/im.test(t)) return '代码'; if (/^\s*[-+]?\d+(\.\d+)?\s*[,,]\s*[-+]?\d+(\.\d+)?\s*$/.test(t)) return '坐标'; if (t.length > 500) return '长文本'; return '文本'; } function summarize(content, type) { if (type === '图片') return '图片剪贴板内容'; const clean = String(content || '').replace(/\s+/g, ' ').trim(); return clean.length > 90 ? clean.slice(0, 90) + '…' : clean || '(空内容)'; } function getSelectedTextDeep() { const active = document.activeElement; let text = ''; try { if (active && (active.tagName === 'TEXTAREA' || (active.tagName === 'INPUT' && /text|search|url|email|tel|password|number/.test(active.type || 'text')))) { const start = active.selectionStart; const end = active.selectionEnd; if (typeof start === 'number' && typeof end === 'number' && end > start) { text = active.value.slice(start, end); } } } catch (_) {} if (!text) { try { text = String(window.getSelection ? window.getSelection() : '').trim(); } catch (_) {} } if (!text && active && active.isContentEditable) { try { text = String(window.getSelection ? window.getSelection() : '').trim(); } catch (_) {} } return String(text || '').trim(); } function captureText(text, source = 'copy-selection') { if (!text || !String(text).trim()) return false; if (state.settings.skipSecrets && looksSecret(text)) { if (source !== 'auto') toast('检测到隐私内容,已跳过保存'); return false; } const content = state.settings.privacyMode ? maskSecret(text) : text; const ok = addRecordSmart({ type: classifyText(text), content, source }); if (ok) { saveRecords(); render(); if (state.settings.showCaptureToast) toast('已自动捕获复制内容'); } return ok; } function captureCurrentSelection(source = 'copy-selection') { const text = getSelectedTextDeep() || ((Date.now() - state.recentSelectionAt < 1500) ? state.recentSelectionText : ''); return captureText(text, source); } async function readClipboard(source = 'manual') { if (!navigator.clipboard) { const ok = captureCurrentSelection('selection-fallback'); if (!ok && source !== 'auto') toast('当前页面不支持 Clipboard API,也没有选中文本'); return ok ? 1 : 0; } let added = 0; const suppressToast = source === 'auto'; try { if (navigator.clipboard.read && state.settings.saveImages && source !== 'selection') { const items = await navigator.clipboard.read(); for (const item of items) { const imgType = item.types.find(t => t.startsWith('image/')); if (imgType) { const blob = await item.getType(imgType); const processed = state.settings.compressImages ? await compressImageBlob(blob) : blob; if (processed.size > APP.maxImageBytes) { if (!suppressToast) toast('图片太大,已跳过'); continue; } const dataUrl = await blobToDataURL(processed); const ok = addRecordSmart({ type: '图片', content: dataUrl, mime: processed.type || imgType, size: processed.size, source }); if (ok) added++; } } } } catch (_) {} try { const text = await navigator.clipboard.readText(); if (text && captureText(text, source === 'auto' ? 'clipboard-auto' : 'clipboard-manual')) added++; } catch (_) { if (captureCurrentSelection(source === 'auto' ? 'selection-auto' : 'selection-fallback')) added++; } if (added) { saveRecords(); render(); if (!suppressToast) toast(`已收集 ${added} 条剪贴板内容`); } else if (!suppressToast) { toast('没有发现新的剪贴板内容;可先选中文字再复制'); } return added; } function addRecordSmart(input) { const rec = { id: uid(), type: input.type, content: input.content, mime: input.mime || 'text/plain', size: input.size || String(input.content || '').length, summary: summarize(input.content, input.type), createdAt: nowText(), updatedAt: nowText(), pageTitle: document.title || '', pageUrl: location.href, source: input.source || 'manual', favorite: false, note: '', tags: autoTags(input) }; const fingerprint = makeFingerprint(rec); if (fingerprint === state.lastFingerprint) return false; state.lastFingerprint = fingerprint; const old = findDuplicate(rec); if (old && state.settings.updateDuplicateTime) { old.updatedAt = nowText(); old.createdAt = nowText(); old.pageTitle = rec.pageTitle; old.pageUrl = rec.pageUrl; old.source = rec.source; old.size = rec.size; old.summary = rec.summary; old.tags = [...new Set([...(old.tags || []), ...(rec.tags || [])])].slice(0, 8); state.records = [old, ...state.records.filter(r => r.id !== old.id)]; state.selectedId = old.id; return true; } state.records.unshift(rec); state.selectedId = rec.id; return true; } function stableTrim(s) { return String(s || '').replace(/\s+/g, ' ').trim(); } function makeFingerprint(rec) { if (rec.type === '图片') return 'image:' + String(rec.content).slice(0, 180) + ':' + rec.size; return 'text:' + stableTrim(rec.content); } function findDuplicate(rec) { const normalized = stableTrim(rec.content); return state.records.find(r => { if (r.type !== rec.type) return false; if (rec.type === '图片') return r.content === rec.content || (String(r.content).slice(0, 220) === String(rec.content).slice(0, 220) && r.size === rec.size); return stableTrim(r.content) === normalized; }); } function blobToDataURL(blob) { return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = reject; r.readAsDataURL(blob); }); } async function compressImageBlob(blob) { try { const img = await blobToImage(blob); const maxW = Number(state.settings.imageMaxWidth || 1280); let w = img.naturalWidth || img.width; let h = img.naturalHeight || img.height; if (w > maxW) { h = Math.round(h * maxW / w); w = maxW; } const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; canvas.getContext('2d').drawImage(img, 0, 0, w, h); const type = blob.type === 'image/png' ? 'image/png' : 'image/jpeg'; const quality = Math.max(.35, Math.min(.95, Number(state.settings.imageQuality || .82))); return await new Promise(resolve => canvas.toBlob(b => resolve(b || blob), type, quality)); } catch (_) { return blob; } } function blobToImage(blob) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { URL.revokeObjectURL(img.src); resolve(img); }; img.onerror = reject; img.src = URL.createObjectURL(blob); }); } function autoTags(input) { const tags = [input.type]; if (input.source) tags.push(input.source); const text = String(input.content || ''); if (input.type === '链接') { try { tags.push(new URL(text).hostname.replace(/^www\./, '')); } catch (_) {} } if (input.type === '代码') { if (/function|const|let|=>/.test(text)) tags.push('JavaScript'); if (/def |import |print\(/.test(text)) tags.push('Python'); if (/SELECT|INSERT|UPDATE|DELETE/i.test(text)) tags.push('SQL'); } if (input.type === '图片') tags.push('图片'); if (/TODO|todo|待办/.test(text)) tags.push('TODO'); return [...new Set(tags)].slice(0, 8); } function getFilteredRecords() { const kw = state.keyword.trim().toLowerCase(); return state.records.filter(r => { const typeOK = state.activeType === '全部' || (state.activeType === '收藏' ? r.favorite : r.type === state.activeType); if (!typeOK) return false; if (!kw) return true; return [r.summary, r.content, r.createdAt, r.updatedAt, r.pageTitle, r.pageUrl, r.source, (r.tags || []).join(' '), r.note] .join(' ').toLowerCase().includes(kw); }); } function types() { const customTypes = [...new Set((state.rules || []).map(r => r.type || r.name).filter(Boolean))]; return [...new Set(['全部', '收藏', '文本', '链接', '代码', 'JSON', '图片', '长文本', '隐私', '邮箱', '坐标', ...customTypes])]; } function ensureUI() { if (document.getElementById('sc-smart-clipboard-root')) return; GM_addStyle(` #sc-smart-clipboard-root, #sc-smart-clipboard-root * { box-sizing: border-box; } #sc-smart-clipboard-root { position: fixed; z-index: 2147483647; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; color: #eef2ff; } .sc-float { position: fixed; right: 0; top: var(--sc-top,260px); width: 46px; height: 58px; border-radius: 18px 0 0 18px; border: 1px solid rgba(255,255,255,.24); border-right:0; background: linear-gradient(135deg,#7c3aed,#06b6d4); color: white; font-size: 24px; box-shadow: 0 18px 45px rgba(0,0,0,.35); cursor: grab; display:flex; align-items:center; justify-content:center; user-select:none; touch-action:none; } .sc-float:active { cursor: grabbing; } .sc-float::before { content:""; position:absolute; left:5px; top:16px; width:3px; height:26px; border-radius:999px; background:rgba(255,255,255,.45); box-shadow:6px 0 0 rgba(255,255,255,.35); } .sc-panel { position: fixed; right: 56px; top: max(16px, min(var(--sc-panel-top,90px), calc(100vh - min(740px, calc(100vh - 32px))))); width: min(1080px, calc(100vw - 76px)); height: min(740px, calc(100vh - 32px)); background: rgba(15,23,42,.94); backdrop-filter: blur(18px); border: 1px solid rgba(148,163,184,.28); border-radius: 24px; box-shadow: 0 30px 90px rgba(0,0,0,.55); overflow: hidden; display: none; } .sc-panel.sc-show { display: grid; grid-template-rows: 68px 1fr 42px; } .sc-light .sc-panel { background: rgba(255,255,255,.96); color:#0f172a; } .sc-head { display:flex; align-items:center; gap:12px; padding: 14px 16px; border-bottom: 1px solid rgba(148,163,184,.22); } .sc-logo { width:40px; height:40px; border-radius: 14px; display:flex; align-items:center; justify-content:center; background: linear-gradient(135deg,#8b5cf6,#22d3ee); color:white; font-size:21px; } .sc-title { font-weight:800; font-size:18px; line-height:1.1; } .sc-sub { font-size:12px; color:#94a3b8; margin-top:3px; } .sc-search { flex:1; display:flex; gap:8px; align-items:center; margin-left:10px; } .sc-input { width:100%; background: rgba(255,255,255,.08); color: inherit; border: 1px solid rgba(148,163,184,.26); outline:none; border-radius: 14px; padding: 11px 13px; } .sc-light .sc-input { background:#f8fafc; } .sc-btn { border:1px solid rgba(148,163,184,.25); background:rgba(255,255,255,.08); color:inherit; border-radius: 13px; padding: 10px 12px; cursor:pointer; font-weight:650; white-space:nowrap; } .sc-btn:hover { background: rgba(125,92,246,.25); } .sc-primary { background: linear-gradient(135deg,#7c3aed,#0891b2); border:0; color:white; } .sc-body { display:grid; grid-template-columns: 188px 370px 1fr; min-height:0; } .sc-side { border-right:1px solid rgba(148,163,184,.2); padding:12px; overflow:auto; } .sc-type { display:flex; justify-content:space-between; align-items:center; width:100%; padding:10px 12px; border-radius:14px; cursor:pointer; color:#cbd5e1; margin-bottom:5px; } .sc-type:hover, .sc-type.active { background:rgba(124,58,237,.25); color:white; } .sc-light .sc-type { color:#334155; } .sc-light .sc-type:hover, .sc-light .sc-type.active { color:#0f172a; background:#e0e7ff; } .sc-list { border-right:1px solid rgba(148,163,184,.2); overflow:auto; padding:12px; } .sc-toolbar { display:flex; gap:6px; margin-bottom:10px; flex-wrap:wrap; } .sc-mini { padding:7px 9px; font-size:12px; border-radius:11px; } .sc-card { border:1px solid rgba(148,163,184,.18); background:rgba(255,255,255,.055); border-radius:18px; padding:12px; margin-bottom:10px; cursor:pointer; position:relative; } .sc-card:hover, .sc-card.active { border-color:rgba(34,211,238,.65); background:rgba(34,211,238,.12); } .sc-card.checked { outline:2px solid rgba(124,58,237,.7); } .sc-check { position:absolute; right:10px; bottom:10px; transform:scale(1.1); } .sc-light .sc-card { background:#f8fafc; } .sc-card-top { display:flex; justify-content:space-between; gap:8px; align-items:center; margin-bottom:8px; padding-right:20px; } .sc-badge { font-size:12px; padding:4px 8px; border-radius:999px; background:rgba(99,102,241,.22); color:#c7d2fe; } .sc-light .sc-badge { background:#e0e7ff; color:#3730a3; } .sc-time { color:#94a3b8; font-size:11px; } .sc-summary { color:#e2e8f0; font-size:13px; line-height:1.45; word-break:break-all; } .sc-light .sc-summary { color:#0f172a; } .sc-tags { display:flex; flex-wrap:wrap; gap:5px; margin-top:9px; padding-right:28px; } .sc-tag { color:#94a3b8; background:rgba(148,163,184,.12); border-radius:999px; padding:2px 7px; font-size:11px; } .sc-preview { padding:16px; overflow:auto; min-width:0; } .sc-empty { color:#94a3b8; height:100%; display:flex; align-items:center; justify-content:center; text-align:center; line-height:1.8; } .sc-preview-head { display:flex; justify-content:space-between; align-items:flex-start; gap:10px; margin-bottom:14px; } .sc-preview-title { font-weight:800; font-size:18px; } .sc-actions { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; } .sc-content { white-space:pre-wrap; word-break:break-word; border:1px solid rgba(148,163,184,.22); border-radius:18px; padding:14px; background:rgba(2,6,23,.35); line-height:1.55; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:13px; } .sc-light .sc-content { background:#f8fafc; } .sc-img { max-width:100%; border-radius:18px; border:1px solid rgba(148,163,184,.25); background:#0f172a; } .sc-meta { margin:12px 0; color:#94a3b8; font-size:12px; line-height:1.7; word-break:break-all; } .sc-foot { display:flex; align-items:center; justify-content:space-between; padding:0 16px; color:#94a3b8; font-size:12px; border-top:1px solid rgba(148,163,184,.2); } .sc-note, .sc-rules { width:100%; min-height:70px; resize:vertical; background:rgba(255,255,255,.07); color:inherit; border:1px solid rgba(148,163,184,.25); border-radius:14px; padding:10px; outline:none; } .sc-rules { min-height:150px; font-family:ui-monospace, Menlo, Consolas, monospace; font-size:12px; } .sc-light .sc-note, .sc-light .sc-rules { background:#f8fafc; } .sc-danger:hover { background:rgba(239,68,68,.25); } .sc-switch { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:8px 0; color:#cbd5e1; font-size:13px; } .sc-number { width:70px; background:rgba(255,255,255,.08); color:inherit; border:1px solid rgba(148,163,184,.25); border-radius:10px; padding:6px; } .sc-light .sc-switch { color:#334155; } .sc-light .sc-number { background:#f8fafc; } @media (max-width: 900px) { .sc-panel { right:50px; width:calc(100vw - 60px); height:calc(100vh - 32px); } .sc-body { grid-template-columns: 120px 1fr; } .sc-preview { display:none; } .sc-panel.sc-detail .sc-side,.sc-panel.sc-detail .sc-list{display:none} .sc-panel.sc-detail .sc-preview{display:block; grid-column:1/3} .sc-head { gap:8px; } .sc-title { font-size:16px; } .sc-sub { display:none; } } `); const root = document.createElement('div'); root.id = 'sc-smart-clipboard-root'; if (state.settings.theme === 'light') root.classList.add('sc-light'); root.style.setProperty('--sc-top', `${Number(state.settings.floatTop || 260)}px`); root.style.setProperty('--sc-panel-top', `${Math.max(16, Number(state.settings.floatTop || 260) - 20)}px`); root.innerHTML = `
✂️
智能剪切板
v${APP.version} · 复制时主动捕获
就绪Ctrl/⌘+C 自动捕获 · Alt+Shift+C 打开
`; document.documentElement.appendChild(root); state.root = root; state.panel = root.querySelector('.sc-panel'); initFloatDrag(root.querySelector('.sc-float')); root.querySelector('.sc-close').addEventListener('click', hidePanel); root.querySelector('.sc-read').addEventListener('click', () => readClipboard('manual')); root.querySelector('.sc-search-input').addEventListener('input', e => { state.keyword = e.target.value; render(); }); render(); } function initFloatDrag(el) { let startY = 0, startTop = 0, moved = false; const clampTop = top => Math.max(12, Math.min(window.innerHeight - 70, top)); const applyTop = top => { top = clampTop(top); state.root.style.setProperty('--sc-top', `${top}px`); state.root.style.setProperty('--sc-panel-top', `${Math.max(16, top - 20)}px`); state.settings.floatTop = top; }; el.addEventListener('pointerdown', e => { moved = false; startY = e.clientY; startTop = Number(state.settings.floatTop || 260); el.setPointerCapture(e.pointerId); }); el.addEventListener('pointermove', e => { if (!el.hasPointerCapture(e.pointerId)) return; const dy = e.clientY - startY; if (Math.abs(dy) > 3) moved = true; applyTop(startTop + dy); }); el.addEventListener('pointerup', e => { try { el.releasePointerCapture(e.pointerId); } catch (_) {} saveSettings(); if (!moved) togglePanel(); }); } function render() { ensureUI(); renderSide(); renderList(); renderPreview(); const bytes = estimateStorageBytes(); const status = document.querySelector('#sc-smart-clipboard-root .sc-status'); if (status) status.textContent = `${state.records.length}/${state.settings.maxRecords} 条 · ${formatSize(bytes)}/${state.settings.maxStorageMB}MB · ${state.activeType}`; } function renderSide() { const side = document.querySelector('#sc-smart-clipboard-root .sc-side'); const counts = {}; for (const r of state.records) { counts[r.type] = (counts[r.type] || 0) + 1; if (r.favorite) counts['收藏'] = (counts['收藏'] || 0) + 1; } counts['全部'] = state.records.length; side.innerHTML = types().map(t => `
${escapeHtml(t)}${counts[t] || 0}
`).join('') + `
`; side.querySelectorAll('.sc-type').forEach(el => el.onclick = () => { state.activeType = el.dataset.type; state.selectedIds.clear(); render(); }); side.querySelectorAll('[data-setting]').forEach(el => el.onchange = () => { state.settings[el.dataset.setting] = el.checked; saveSettings(); toast('设置已保存'); }); side.querySelectorAll('[data-number]').forEach(el => el.onchange = () => { state.settings[el.dataset.number] = Number(el.value); saveSettings(); saveRecords(); render(); toast('维护策略已更新'); }); side.querySelector('[data-action="toggle-theme"]').onclick = () => { state.settings.theme = state.settings.theme === 'dark' ? 'light' : 'dark'; saveSettings(); document.getElementById('sc-smart-clipboard-root').classList.toggle('sc-light', state.settings.theme === 'light'); }; side.querySelector('[data-action="rules"]').onclick = showRulesEditor; side.querySelector('[data-action="maintain"]').onclick = () => { maintainRecords(true); saveRecords(); render(); }; side.querySelector('[data-action="export-json"]').onclick = exportJSON; side.querySelector('[data-action="clear-all"]').onclick = () => { if (confirm('确定清空全部剪贴板历史吗?')) { state.records = []; state.selectedId = null; state.selectedIds.clear(); saveRecords(); render(); } }; } function renderList() { const list = document.querySelector('#sc-smart-clipboard-root .sc-list'); const records = getFilteredRecords(); if (!records.length) { list.innerHTML = `
暂无记录
现在不靠“读最近一次剪贴板”
请在网页里选中文字后 Ctrl/⌘+C,会自动记录
`; return; } if (!state.selectedId || !records.some(r => r.id === state.selectedId)) state.selectedId = records[0].id; list.innerHTML = `
` + records.map(r => `
${r.favorite ? '★ ' : ''}${escapeHtml(r.type)}${escapeHtml(r.updatedAt || r.createdAt)}
${r.type === '图片' ? '' : escapeHtml(r.summary)}
${(r.tags || []).map(t => `${escapeHtml(t)}`).join('')}
`).join(''); list.querySelectorAll('.sc-card').forEach(card => { card.addEventListener('click', e => { if (e.target.classList.contains('sc-check')) return; state.selectedId = card.dataset.id; state.panel.classList.add('sc-detail'); render(); }); card.querySelector('.sc-check').addEventListener('change', e => { if (e.target.checked) state.selectedIds.add(card.dataset.id); else state.selectedIds.delete(card.dataset.id); render(); }); }); const currentIds = records.map(r => r.id); list.querySelector('[data-batch="select-all"]').onclick = () => { currentIds.forEach(id => state.selectedIds.add(id)); render(); }; list.querySelector('[data-batch="clear"]').onclick = () => { state.selectedIds.clear(); render(); }; list.querySelector('[data-batch="fav"]').onclick = () => { state.records.forEach(r => { if (state.selectedIds.has(r.id)) r.favorite = true; }); saveRecords(); render(); }; list.querySelector('[data-batch="export"]').onclick = exportSelected; list.querySelector('[data-batch="delete"]').onclick = () => { if (!state.selectedIds.size) return toast('请先选择记录'); if (confirm(`删除已选 ${state.selectedIds.size} 条?`)) { state.records = state.records.filter(r => !state.selectedIds.has(r.id)); state.selectedIds.clear(); state.selectedId = null; saveRecords(); render(); } }; } function renderPreview() { const box = document.querySelector('#sc-smart-clipboard-root .sc-preview'); const rec = state.records.find(r => r.id === state.selectedId); if (!rec) { box.innerHTML = `
选择一条记录查看详情
`; return; } const contentHtml = rec.type === '图片' ? `clipboard image` : `
${escapeHtml(formatContent(rec))}
`; box.innerHTML = `
${rec.favorite ? '★ ' : ''}${escapeHtml(rec.type)} · ${escapeHtml(rec.updatedAt || rec.createdAt)}
来源:${escapeHtml(rec.pageTitle || '未知页面')}
${escapeHtml(rec.pageUrl || '')}
大小:${formatSize(rec.size)} · MIME:${escapeHtml(rec.mime || '')} · 捕获:${escapeHtml(rec.source || 'manual')}
${contentHtml}
标签:${(rec.tags || []).map(escapeHtml).join(' / ') || '无'}
`; box.querySelector('[data-action="back"]').onclick = () => state.panel.classList.remove('sc-detail'); box.querySelector('[data-action="copy"]').onclick = () => copyRecord(rec); box.querySelector('[data-action="favorite"]').onclick = () => { rec.favorite = !rec.favorite; saveRecords(); render(); }; box.querySelector('[data-action="download"]').onclick = () => downloadRecord(rec); box.querySelector('[data-action="delete"]').onclick = () => { state.records = state.records.filter(r => r.id !== rec.id); state.selectedId = null; state.selectedIds.delete(rec.id); saveRecords(); render(); }; box.querySelector('.sc-note').addEventListener('change', e => { rec.note = e.target.value; saveRecords(); renderList(); }); } function showRulesEditor() { showPanel(); const box = document.querySelector('#sc-smart-clipboard-root .sc-preview'); state.panel.classList.add('sc-detail'); box.innerHTML = `
自定义分类规则
每行一个规则:分类名 = 正则表达式。越靠前优先级越高。
示例:财务 = 发票|报销|invoice|receipt
`; box.querySelector('[data-action="back"]').onclick = () => { state.panel.classList.remove('sc-detail'); render(); }; box.querySelector('[data-action="reset"]').onclick = () => { state.rules = JSON.parse(JSON.stringify(defaultRules)); saveRules(); render(); toast('已恢复默认规则'); }; box.querySelector('[data-action="save"]').onclick = () => { const lines = box.querySelector('.sc-rules').value.split(/\n+/).map(s => s.trim()).filter(Boolean); const rules = []; for (const line of lines) { const idx = line.indexOf('='); if (idx <= 0) continue; const type = line.slice(0, idx).trim(); const pattern = line.slice(idx + 1).trim(); if (!type || !pattern) continue; try { new RegExp(pattern, 'i'); rules.push({ name: type, type, pattern }); } catch (_) { return toast(`规则正则错误:${type}`); } } state.rules = rules; saveRules(); render(); toast('分类规则已保存'); }; } function formatContent(rec) { if (rec.type === 'JSON') { try { return JSON.stringify(JSON.parse(rec.content), null, 2); } catch (_) {} } return rec.content; } function formatSize(n) { n = Number(n || 0); if (n > 1024 * 1024) return (n / 1024 / 1024).toFixed(2) + ' MB'; if (n > 1024) return (n / 1024).toFixed(1) + ' KB'; return n + ' B'; } function copyRecord(rec) { if (rec.type === '图片') { copyImage(rec.content).then(() => toast('图片已复制')).catch(() => { GM_setClipboard(rec.content, 'text'); toast('图片复制受限,已复制 DataURL'); }); return; } GM_setClipboard(rec.content, 'text'); toast('已复制到剪贴板'); } async function copyImage(dataUrl) { const res = await fetch(dataUrl); const blob = await res.blob(); if (!navigator.clipboard || !window.ClipboardItem) throw new Error('unsupported'); await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); } function downloadRecord(rec) { if (rec.type === '图片') downloadBlob(dataURLToBlob(rec.content), `智能剪切板_${(rec.updatedAt || rec.createdAt).replace(/[: ]/g,'_')}.png`); else downloadBlob(new Blob([formatContent(rec)], {type:'text/plain;charset=utf-8'}), `智能剪切板_${(rec.updatedAt || rec.createdAt).replace(/[: ]/g,'_')}.txt`); } function dataURLToBlob(dataurl) { const arr = dataurl.split(','); const mime = arr[0].match(/:(.*?);/)?.[1] || 'application/octet-stream'; const bstr = atob(arr[1]); let n = bstr.length; const u8arr = new Uint8Array(n); while(n--) u8arr[n] = bstr.charCodeAt(n); return new Blob([u8arr], {type:mime}); } function downloadBlob(blob, filename) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); } function exportJSON() { downloadBlob(new Blob([JSON.stringify(state.records, null, 2)], {type:'application/json;charset=utf-8'}), '智能剪切板_导出.json'); } function exportSelected() { if (!state.selectedIds.size) return toast('请先选择记录'); const records = state.records.filter(r => state.selectedIds.has(r.id)); downloadBlob(new Blob([JSON.stringify(records, null, 2)], {type:'application/json;charset=utf-8'}), '智能剪切板_所选导出.json'); } function toast(msg) { let el = document.getElementById('sc-smart-toast'); if (!el) { el = document.createElement('div'); el.id = 'sc-smart-toast'; el.style.cssText = 'position:fixed;z-index:2147483647;left:50%;bottom:34px;transform:translateX(-50%);background:rgba(15,23,42,.95);color:#fff;border:1px solid rgba(255,255,255,.18);border-radius:999px;padding:10px 16px;font:14px system-ui;box-shadow:0 14px 40px rgba(0,0,0,.35);opacity:0;transition:.2s;pointer-events:none'; document.body.appendChild(el); } el.textContent = msg; el.style.opacity = '1'; clearTimeout(el._t); el._t = setTimeout(() => el.style.opacity = '0', 1800); } function showPanel() { ensureUI(); state.visible = true; state.panel.classList.add('sc-show'); render(); } function hidePanel() { state.visible = false; state.panel.classList.remove('sc-show', 'sc-detail'); } function togglePanel() { state.visible ? hidePanel() : showPanel(); } function rememberSelection() { const text = getSelectedTextDeep(); if (text) { state.recentSelectionText = text; state.recentSelectionAt = Date.now(); } } function tryAutoCapture(reason) { if (!state.settings.autoCapture) return; const now = Date.now(); if (now - state.lastCaptureAt < 240) return; state.lastCaptureAt = now; rememberSelection(); const ok = captureCurrentSelection(reason); if (!ok && state.settings.autoReadClipboardAfterCopy) { setTimeout(() => readClipboard('auto'), 80); } } function initEvents() { document.addEventListener('keydown', e => { if (e.altKey && e.shiftKey && e.code === 'KeyC') { e.preventDefault(); togglePanel(); return; } const isCopy = (e.ctrlKey || e.metaKey) && !e.shiftKey && String(e.key).toLowerCase() === 'c'; if (isCopy && state.settings.captureKeyboardCopy) { rememberSelection(); setTimeout(() => tryAutoCapture('keyboard-copy'), 0); } if (state.visible && e.key === 'Escape') hidePanel(); }, true); document.addEventListener('selectionchange', () => { const text = getSelectedTextDeep(); if (text) { state.recentSelectionText = text; state.recentSelectionAt = Date.now(); } }, true); document.addEventListener('copy', e => { if (!state.settings.captureCopyEvent) return; let text = ''; try { if (e.clipboardData) text = e.clipboardData.getData('text/plain') || ''; } catch (_) {} if (text) captureText(text, 'copy-event'); else setTimeout(() => tryAutoCapture('copy-event'), 0); }, true); document.addEventListener('cut', () => setTimeout(() => tryAutoCapture('cut-event'), 0), true); document.addEventListener('mouseup', () => { if (!state.settings.captureSelectionOnMouse) return; rememberSelection(); }, true); try { GM_registerMenuCommand('打开智能剪切板', showPanel); GM_registerMenuCommand('读取当前剪贴板/图片', () => readClipboard('manual')); GM_registerMenuCommand('编辑分类规则', showRulesEditor); GM_registerMenuCommand('立即维护:7天/50条/容量', () => { maintainRecords(true); saveRecords(); render(); }); GM_registerMenuCommand('导出剪贴板 JSON', exportJSON); GM_registerMenuCommand('清空剪贴板历史', () => { if (confirm('确定清空全部剪贴板历史吗?')) { state.records = []; saveRecords(); render(); } }); } catch (_) {} } loadRecords(); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { ensureUI(); render(); }); } else { ensureUI(); } initEvents(); })();