// ==UserScript==
// @name Eagle 一键收图工具
// @namespace http://tampermonkey.net/
// @version 4.0
// @description 一键将图片发送到 Eagle。配合"图片全载Next"脚本使用。
// @author ai
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @connect localhost
// @connect *
// @run-at document-end
// @downloadURL https://update.greasyfork.icu/scripts/563933/Eagle%20%E4%B8%80%E9%94%AE%E6%94%B6%E5%9B%BE%E5%B7%A5%E5%85%B7.user.js
// @updateURL https://update.greasyfork.icu/scripts/563933/Eagle%20%E4%B8%80%E9%94%AE%E6%94%B6%E5%9B%BE%E5%B7%A5%E5%85%B7.meta.js
// ==/UserScript==
(function () {
'use strict';
console.log('[Eagle] 脚本已加载 v3.0');
// ==================== 设置界面 ====================
function openSettings() {
const { defaultRule, siteRules } = loadRules();
const modal = document.createElement('div');
modal.id = 'eagle-settings-modal';
modal.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8);
z-index: 2147483647; display: flex; align-items: center; justify-content: center;
`;
const panel = document.createElement('div');
panel.style.cssText = `
background: #1a1a1a; border-radius: 8px; width: 90%; max-width: 800px; max-height: 90vh;
overflow-y: auto; padding: 20px; color: white;
`;
panel.innerHTML = `
Eagle 收图规则设置
`;
modal.appendChild(panel);
document.body.appendChild(modal);
// 渲染规则编辑器
function renderRuleEditor(rule, container) {
container.innerHTML = `
`;
// 切换自定义文本框显示
['name', 'annotation', 'website'].forEach(field => {
const select = container.querySelector(`.rule-${field}-source`);
const customInput = container.querySelector(`.rule-${field}-custom`);
select.addEventListener('change', () => {
customInput.style.display = select.value === 'custom' ? 'block' : 'none';
});
});
}
// 默认规则折叠切换
let defaultExpanded = false;
const defaultToggleBtn = panel.querySelector('#default-rule-toggle');
const defaultEditor = panel.querySelector('#default-rule-editor');
panel.querySelector('#default-rule-header').addEventListener('click', () => {
defaultExpanded = !defaultExpanded;
defaultEditor.style.display = defaultExpanded ? 'block' : 'none';
defaultEditor.style.paddingTop = defaultExpanded ? '15px' : '0';
defaultToggleBtn.textContent = defaultExpanded ? '收起' : '编辑';
defaultToggleBtn.style.background = defaultExpanded ? '#888' : '#2196F3';
if (defaultExpanded && !defaultEditor.hasChildNodes()) {
renderRuleEditor(defaultRule, defaultEditor);
}
});
// 渲染站点规则列表(紧凑模式,点击编辑展开)
function renderSiteRulesList(expandIndex = -1) {
const list = panel.querySelector('#site-rules-list');
list.innerHTML = '';
siteRules.forEach((rule, index) => {
const ruleDiv = document.createElement('div');
ruleDiv.style.cssText = 'background: #333; border-radius: 6px; margin-bottom: 6px; overflow: hidden;';
const isExpanded = index === expandIndex;
// 紧凑行:显示所有 URL 模式
const patterns = rule.urlPatterns || (rule.urlPattern ? [rule.urlPattern] : []);
const urlLabel = patterns.length > 0 ? patterns.join(' | ') : '(未设置 URL)';
const headerRow = document.createElement('div');
headerRow.style.cssText = 'display: flex; align-items: center; padding: 8px 12px; gap: 8px;';
headerRow.innerHTML = `
${urlLabel}
`;
// 展开编辑区
const editorArea = document.createElement('div');
editorArea.style.cssText = `padding: ${isExpanded ? '12px' : '0'}; border-top: ${isExpanded ? '1px solid #444' : 'none'}; display: ${isExpanded ? 'block' : 'none'};`;
if (isExpanded) {
// 多 URL 区域
const urlSection = document.createElement('div');
urlSection.style.cssText = 'margin-bottom: 10px;';
urlSection.innerHTML = ``;
const urlInputsWrap = document.createElement('div');
urlInputsWrap.className = 'rule-url-inputs';
function addUrlInput(val) {
const row = document.createElement('div');
row.style.cssText = 'display: flex; gap: 6px; margin-bottom: 5px;';
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'rule-url-pattern';
inp.value = val;
inp.placeholder = '例:*.example.com/*';
inp.style.cssText = 'flex: 1; padding: 7px; background: #222; color: white; border: 1px solid #555; border-radius: 4px; box-sizing: border-box;';
inp.addEventListener('input', updateHeaderLabel);
const rmBtn = document.createElement('button');
rmBtn.textContent = '×';
rmBtn.style.cssText = 'padding: 4px 10px; background: #555; color: white; border: none; border-radius: 4px; cursor: pointer; flex-shrink: 0;';
rmBtn.addEventListener('click', () => { row.remove(); updateHeaderLabel(); });
row.appendChild(inp);
row.appendChild(rmBtn);
urlInputsWrap.appendChild(row);
}
function updateHeaderLabel() {
const vals = [...urlInputsWrap.querySelectorAll('.rule-url-pattern')].map(i => i.value.trim()).filter(Boolean);
headerRow.querySelector('.rule-url-label').textContent = vals.length ? vals.join(' | ') : '(未设置 URL)';
}
patterns.forEach(p => addUrlInput(p));
if (patterns.length === 0) addUrlInput('');
const addUrlBtn = document.createElement('button');
addUrlBtn.textContent = '+ 添加网址';
addUrlBtn.style.cssText = 'margin-top: 2px; padding: 4px 12px; background: #444; color: #ccc; border: 1px dashed #666; border-radius: 4px; cursor: pointer; font-size: 12px;';
addUrlBtn.addEventListener('click', () => { addUrlInput(''); });
urlSection.appendChild(urlInputsWrap);
urlSection.appendChild(addUrlBtn);
editorArea.appendChild(urlSection);
const editorSlot = document.createElement('div');
editorSlot.className = 'rule-editor-slot';
editorArea.appendChild(editorSlot);
renderRuleEditor(rule, editorSlot);
}
// 编辑/收起按钮
headerRow.querySelector('.edit-rule-btn').addEventListener('click', () => {
syncExpandedRule(currentExpandIndex);
currentExpandIndex = isExpanded ? -1 : index;
renderSiteRulesList(currentExpandIndex);
});
// 删除按钮
headerRow.querySelector('.delete-rule-btn').addEventListener('click', () => {
syncExpandedRule(currentExpandIndex);
siteRules.splice(index, 1);
currentExpandIndex = -1;
renderSiteRulesList();
});
ruleDiv.appendChild(headerRow);
ruleDiv.appendChild(editorArea);
list.appendChild(ruleDiv);
});
}
renderSiteRulesList();
// 将当前展开的规则表单值同步回 siteRules
function syncExpandedRule(expandIndex) {
if (expandIndex < 0 || expandIndex >= siteRules.length) return;
const list = panel.querySelector('#site-rules-list');
const ruleDivs = list.querySelectorAll(':scope > div');
const ruleDiv = ruleDivs[expandIndex];
if (!ruleDiv) return;
const urlInputs = ruleDiv.querySelectorAll('.rule-url-pattern');
const nameSource = ruleDiv.querySelector('.rule-name-source');
if (!urlInputs.length || !nameSource) return; // 未展开
const urlPatterns = [...urlInputs].map(el => el.value.trim()).filter(Boolean);
siteRules[expandIndex] = {
urlPatterns,
urlPattern: urlPatterns[0] || '', // 向后兼容
name: {
source: ruleDiv.querySelector('.rule-name-source').value,
customText: ruleDiv.querySelector('.rule-name-custom').value,
regex: ruleDiv.querySelector('.rule-name-regex').value
},
annotation: {
source: ruleDiv.querySelector('.rule-annotation-source').value,
customText: ruleDiv.querySelector('.rule-annotation-custom').value,
regex: ruleDiv.querySelector('.rule-annotation-regex').value
},
website: {
source: ruleDiv.querySelector('.rule-website-source').value,
customText: ruleDiv.querySelector('.rule-website-custom').value,
regex: ruleDiv.querySelector('.rule-website-regex').value
},
tags: ruleDiv.querySelector('.rule-tags').value.split(',').map(t => t.trim()).filter(t => t)
};
}
// 当前展开的规则索引(闭包共享)
let currentExpandIndex = -1;
// 添加规则按钮
panel.querySelector('#add-rule-btn').addEventListener('click', () => {
syncExpandedRule(currentExpandIndex);
siteRules.push(JSON.parse(JSON.stringify(DEFAULT_RULE)));
currentExpandIndex = siteRules.length - 1;
renderSiteRulesList(currentExpandIndex);
});
// 保存按钮
panel.querySelector('#save-settings-btn').addEventListener('click', () => {
// 先把展开规则的表单内容同步回 siteRules
syncExpandedRule(currentExpandIndex);
// 读取默认规则(可能未展开)
const defEditor = panel.querySelector('#default-rule-editor');
let newDefaultRule;
if (defEditor.hasChildNodes()) {
newDefaultRule = {
urlPattern: '*',
name: {
source: defEditor.querySelector('.rule-name-source').value,
customText: defEditor.querySelector('.rule-name-custom').value,
regex: defEditor.querySelector('.rule-name-regex').value
},
annotation: {
source: defEditor.querySelector('.rule-annotation-source').value,
customText: defEditor.querySelector('.rule-annotation-custom').value,
regex: defEditor.querySelector('.rule-annotation-regex').value
},
website: {
source: defEditor.querySelector('.rule-website-source').value,
customText: defEditor.querySelector('.rule-website-custom').value,
regex: defEditor.querySelector('.rule-website-regex').value
},
tags: defEditor.querySelector('.rule-tags').value.split(',').map(t => t.trim()).filter(t => t)
};
} else {
newDefaultRule = defaultRule; // 未编辑,保持原值
}
saveRules(newDefaultRule, siteRules);
alert('规则已保存!');
modal.remove();
});
// 取消按钮
panel.querySelector('#cancel-settings-btn').addEventListener('click', () => {
modal.remove();
});
}
// ==================== 初始化 ====================
const CONFIG = {
eagleApiUrl: 'http://localhost:41595'
};
// ==================== 规则配置 ====================
const DEFAULT_RULE = {
urlPattern: '*',
name: { source: 'page-title', customText: '', regex: '' },
annotation: { source: 'custom', customText: '', regex: '' },
website: { source: 'url', customText: '', regex: '' },
tags: ['漫画']
};
// 加载规则配置
function loadRules() {
const defaultRule = GM_getValue('eagle_default_rule', DEFAULT_RULE);
const siteRules = GM_getValue('eagle_site_rules', []);
return { defaultRule, siteRules };
}
// 保存规则配置
function saveRules(defaultRule, siteRules) {
GM_setValue('eagle_default_rule', defaultRule);
GM_setValue('eagle_site_rules', siteRules);
}
// URL 模式匹配 (支持通配符)
function matchUrlPattern(pattern, url) {
if (pattern === '*') return true;
const regexPattern = pattern
.replace(/\./g, '\\.')
.replace(/\*/g, '.*')
.replace(/\?/g, '.');
const regex = new RegExp('^' + regexPattern + '$', 'i');
return regex.test(url);
}
// 查找匹配当前URL的规则
function findMatchingRule(url) {
const { defaultRule, siteRules } = loadRules();
for (const rule of siteRules) {
// 支持新字段 urlPatterns(数组)和旧字段 urlPattern(字符串)
const patterns = rule.urlPatterns?.length ? rule.urlPatterns
: rule.urlPattern ? [rule.urlPattern]
: [];
if (patterns.some(p => matchUrlPattern(p, url))) {
console.log('[Eagle] 匹配规则:', patterns.join(', '));
return rule;
}
}
console.log('[Eagle] 使用默认规则');
return defaultRule;
}
// 变量替换
function replaceVariables(text, context) {
return text
.replace(/\{title\}/g, context.title || '')
.replace(/\{filename\}/g, context.filename || '')
.replace(/\{url\}/g, context.url || '')
.replace(/\{domain\}/g, context.domain || '')
.replace(/\{date\}/g, new Date().toISOString().split('T')[0])
.replace(/\{time\}/g, new Date().toTimeString().split(' ')[0]);
}
// 正则提取
function extractByRegex(text, regex) {
if (!regex) return text;
try {
const match = text.match(new RegExp(regex));
return match && match[1] ? match[1] : text;
} catch (e) {
console.error('[Eagle] 正则表达式错误:', e);
return text;
}
}
// 生成元数据
function generateMetadata(imageUrl, rule) {
const context = {
title: document.title,
filename: imageUrl.split('/').pop().split('?')[0],
url: location.href,
domain: location.hostname
};
const getValue = (config) => {
let value = '';
switch (config.source) {
case 'page-title':
value = context.title;
break;
case 'filename':
value = context.filename;
break;
case 'url':
value = context.url;
break;
case 'custom':
value = replaceVariables(config.customText, context);
break;
}
if (config.regex) {
value = extractByRegex(value, config.regex);
}
return value;
};
return {
name: getValue(rule.name),
annotation: getValue(rule.annotation),
website: getValue(rule.website),
tags: rule.tags || []
};
}
// ==================== 发送到 Eagle ====================
function sendToEagle(imageUrl) {
return new Promise((resolve, reject) => {
const apiUrl = `${CONFIG.eagleApiUrl}/api/item/addFromURL`;
console.log('[Eagle] 正在发送到 Eagle...');
console.log('[Eagle] 图片 URL:', imageUrl);
// 使用规则生成元数据
const rule = findMatchingRule(location.href);
const metadata = generateMetadata(imageUrl, rule);
console.log('[Eagle] 使用元数据:', metadata);
GM_xmlhttpRequest({
method: 'POST',
url: apiUrl,
headers: {
'Content-Type': 'application/json'
},
data: JSON.stringify({
url: imageUrl,
name: metadata.name,
website: metadata.website,
annotation: metadata.annotation,
tags: metadata.tags
}),
onload: (response) => {
console.log('[Eagle] API 响应状态:', response.status);
console.log('[Eagle] 响应内容:', response.responseText);
if (response.status >= 200 && response.status < 300) {
try {
const result = JSON.parse(response.responseText);
resolve(result);
} catch (e) {
resolve({ status: 'success' });
}
} else if (response.status === 404) {
reject(new Error('Eagle API 端点不存在\n请确保:\n1. Eagle 应用正在运行\n2. 已在 Eagle 设置中启用 API\n3. Eagle 版本 ≥ 3.0'));
} else {
reject(new Error(`Eagle API 错误: ${response.status}\n${response.responseText}`));
}
},
onerror: (error) => {
console.error('[Eagle] API 连接失败:', error);
reject(new Error('无法连接到 Eagle (http://localhost:41595)\n\n请确保:\n1. Eagle 应用正在运行\n2. 已在 Eagle 设置 → 实验室 中启用 API\n3. API 端口为 41595'));
},
ontimeout: () => {
reject(new Error('Eagle API 请求超时'));
}
});
});
}
// 发送裁剪图片到 Eagle(使用 data: URI 通过 addFromURL 发送)
function sendBase64ToEagle(base64Data, imageUrl) {
return new Promise((resolve, reject) => {
const apiUrl = `${CONFIG.eagleApiUrl}/api/item/addFromURL`;
const rule = findMatchingRule(location.href);
const metadata = generateMetadata(imageUrl || location.href, rule);
GM_xmlhttpRequest({
method: 'POST',
url: apiUrl,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
url: base64Data, // data:image/png;base64,... Eagle 支持 data: URI
name: metadata.name,
website: metadata.website,
annotation: metadata.annotation,
tags: metadata.tags
}),
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
try { resolve(JSON.parse(response.responseText)); }
catch (e) { resolve({ status: 'success' }); }
} else {
reject(new Error(`Eagle API 错误: ${response.status}\n${response.responseText}`));
}
},
onerror: () => reject(new Error('无法连接到 Eagle')),
ontimeout: () => reject(new Error('Eagle API 请求超时'))
});
});
}
// ==================== 裁剪界面 ====================
function openCropUI(imageUrl, pageImgEl) {
// 防止重复打开
if (document.getElementById('eagle-crop-overlay')) return;
// 优先挂载到 Fancybox 容器内,保证层级高于灯箱本身
const fancyboxRoot = document.querySelector('.fancybox__container') || document.body;
const useAbsolute = fancyboxRoot !== document.body;
const overlay = document.createElement('div');
overlay.id = 'eagle-crop-overlay';
overlay.style.cssText = `
position: ${useAbsolute ? 'absolute' : 'fixed'}; inset: 0; z-index: 2147483647;
background: rgba(0,0,0,0.85);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
cursor: crosshair;
`;
// 提示文字
const hint = document.createElement('div');
hint.style.cssText = 'position: absolute; top: 14px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 14px; background: rgba(0,0,0,0.6); padding: 6px 16px; border-radius: 20px; pointer-events: none; white-space: nowrap;';
hint.textContent = '拖拽选择裁剪区域 · ESC 取消';
overlay.appendChild(hint);
// 后台用 GM_xmlhttpRequest 下载无跨域污染的 blob,供裁剪导出用
// cleanImgPromise resolve(HTMLImageElement) when ready
let cleanImgResolve, cleanImgReject;
const cleanImgPromise = new Promise((res, rej) => { cleanImgResolve = res; cleanImgReject = rej; });
GM_xmlhttpRequest({
method: 'GET',
url: imageUrl,
responseType: 'blob',
onload: (resp) => {
const blobUrl = URL.createObjectURL(resp.response);
const blobImg = new Image();
blobImg.onload = () => { cleanImgResolve(blobImg); URL.revokeObjectURL(blobUrl); };
blobImg.onerror = () => cleanImgReject(new Error('blob img load failed'));
blobImg.src = blobUrl;
},
onerror: () => cleanImgReject(new Error('GM download failed'))
});
// 如果有页面已加载的 img 元素,直接用它即时渲染;否则等 GM 下载
if (pageImgEl) {
hint.textContent = '拖拽选择裁剪区域 · ESC 取消';
loadImage(pageImgEl, cleanImgPromise);
} else {
hint.textContent = '图片加载中...';
cleanImgPromise.then(blobImg => {
hint.textContent = '拖拽选择裁剪区域 · ESC 取消';
loadImage(blobImg, Promise.resolve(blobImg));
}).catch(() => {
// GM 失败时用直接加载作为最后手段
hint.textContent = '拖拽选择裁剪区域 · ESC 取消';
const fallbackImg = new Image();
const fallbackPromise = new Promise((res, rej) => {
fallbackImg.onload = () => res(fallbackImg);
fallbackImg.onerror = rej;
});
fallbackImg.src = imageUrl;
fallbackImg.onload = () => loadImage(fallbackImg, fallbackPromise);
});
}
// displayImg: HTMLImageElement 用于即时渲染
// cleanImgPromise: Promise 无跨域污染的图片,供最终裁剪导出
function loadImage(displayImg, cleanImgPromise) {
const img = displayImg;
const naturalW = img.naturalWidth;
const naturalH = img.naturalHeight;
const maxW = window.innerWidth * 0.9;
const maxH = window.innerHeight * 0.85;
// baseScale: 将原图缩放至适合屏幕的比例(viewZoom=1 时使用)
const baseScale = Math.min(maxW / naturalW, maxH / naturalH, 1);
const dispW = Math.round(naturalW * baseScale);
const dispH = Math.round(naturalH * baseScale);
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.width = Math.round(dispW * dpr);
canvas.height = Math.round(dispH * dpr);
canvas.style.cssText = `display: block; width: ${dispW}px; height: ${dispH}px; cursor: crosshair; outline: 2px solid #fff; box-shadow: 0 0 30px rgba(0,0,0,0.8);`;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// ─── 缩放 / 平移状态 ──────────────────────────────────────
let viewZoom = 1; // 额外缩放倍率(1 = 适应屏幕)
let panX = 0; // 图片左上角在画布 CSS px 中的偏移
let panY = 0;
const MIN_ZOOM = 1, MAX_ZOOM = 10;
function clampPan() {
if (viewZoom <= 1) { panX = 0; panY = 0; return; }
panX = Math.min(0, Math.max(dispW - dispW * viewZoom, panX));
panY = Math.min(0, Math.max(dispH - dispH * viewZoom, panY));
}
function redrawCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.scale(dpr, dpr);
// 可见区域在画布 CSS px 中的目标矩形
const dstX = Math.max(0, panX);
const dstY = Math.max(0, panY);
const dstW = Math.min(dispW - dstX, dispW * viewZoom - Math.max(0, -panX));
const dstH = Math.min(dispH - dstY, dispH * viewZoom - Math.max(0, -panY));
if (dstW > 0 && dstH > 0) {
// 对应原图的源矩形
const srcX = Math.max(0, -panX / (baseScale * viewZoom));
const srcY = Math.max(0, -panY / (baseScale * viewZoom));
const srcW = dstW / (baseScale * viewZoom);
const srcH = dstH / (baseScale * viewZoom);
ctx.drawImage(img, srcX, srcY, srcW, srcH, dstX, dstY, dstW, dstH);
}
ctx.restore();
}
redrawCanvas();
overlay.appendChild(canvas);
// 缩放比例标签(右上角)
const zoomLabel = document.createElement('div');
zoomLabel.style.cssText = 'position: absolute; top: 14px; right: 20px; color: #fff; font-size: 13px; background: rgba(0,0,0,0.55); padding: 4px 10px; border-radius: 12px; pointer-events: none; user-select: none;';
zoomLabel.textContent = '100%';
overlay.appendChild(zoomLabel);
// 更新提示文字
hint.textContent = '拖拽选区 · 滚轮缩放 · 中键平移 · ESC取消';
// 提前声明(供 applyZoom 闭包引用)
let cropRect = null;
let selDisplay = null;
// ─── 按钮栏 ───────────────────────────────────────────────
const btnBar = document.createElement('div');
btnBar.style.cssText = 'position: absolute; bottom: 24px; display: flex; gap: 10px; z-index: 10; align-items: center;';
function mkBtn(text, bg, title) {
const b = document.createElement('button');
b.textContent = text;
if (title) b.title = title;
b.style.cssText = `padding: 9px 15px; background: ${bg}; color: #fff; border: none; border-radius: 6px; font-size: 14px; cursor: pointer;`;
return b;
}
const zoomOutBtn = mkBtn('-', '#444', '缩小 (滚轮)');
const zoomResetBtn = mkBtn('100%', '#444', '重置缩放');
const zoomInBtn = mkBtn('+', '#444', '放大 (滚轮)');
const confirmBtn = mkBtn('✓ 裁剪并发送到 Eagle', '#4CAF50');
const cancelBtn = mkBtn('✕ 取消', '#666');
confirmBtn.style.opacity = '0.4';
confirmBtn.style.pointerEvents = 'none';
btnBar.append(zoomOutBtn, zoomResetBtn, zoomInBtn, confirmBtn, cancelBtn);
overlay.appendChild(btnBar);
// ─── 缩放逻辑 ─────────────────────────────────────────────
function applyZoom(newZoom, cx, cy) {
newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
if (newZoom === viewZoom) return;
const factor = newZoom / viewZoom;
panX = cx - (cx - panX) * factor;
panY = cy - (cy - panY) * factor;
viewZoom = newZoom;
clampPan();
redrawCanvas();
const pct = Math.round(viewZoom * 100) + '%';
zoomLabel.textContent = pct;
zoomResetBtn.textContent = pct;
canvas.style.cursor = viewZoom > 1 ? 'grab' : 'crosshair';
// 缩放时清除选区(坐标已失效)
cropRect = null; selDisplay = null;
selDiv.style.display = 'none';
confirmBtn.style.opacity = '0.4';
confirmBtn.style.pointerEvents = 'none';
}
zoomInBtn.addEventListener('click', () => applyZoom(viewZoom * 1.5, dispW / 2, dispH / 2));
zoomOutBtn.addEventListener('click', () => applyZoom(viewZoom / 1.5, dispW / 2, dispH / 2));
zoomResetBtn.addEventListener('click', () => {
viewZoom = 1; panX = 0; panY = 0;
redrawCanvas();
zoomLabel.textContent = zoomResetBtn.textContent = '100%';
canvas.style.cursor = 'crosshair';
cropRect = null; selDisplay = null;
selDiv.style.display = 'none';
confirmBtn.style.opacity = '0.4';
confirmBtn.style.pointerEvents = 'none';
});
// 滚轮缩放(以鼠标为中心)
canvas.addEventListener('wheel', (e) => {
e.preventDefault(); e.stopPropagation();
const rect = canvas.getBoundingClientRect();
applyZoom(
viewZoom * (e.deltaY < 0 ? 1.2 : 1 / 1.2),
e.clientX - rect.left,
e.clientY - rect.top
);
}, { passive: false });
// ─── 选区框 & 手柄 ────────────────────────────────────────
const selDiv = document.createElement('div');
selDiv.style.cssText = 'position: absolute; border: 2px dashed #fff; box-sizing: border-box; display: none; box-shadow: 0 0 0 9999px rgba(0,0,0,0.45); cursor: move; z-index: 1;';
overlay.appendChild(selDiv);
const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
const HANDLE_CURSORS = { nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize', e: 'e-resize', se: 'se-resize', s: 's-resize', sw: 'sw-resize', w: 'w-resize' };
const handleEls = {};
HANDLES.forEach(pos => {
const hEl = document.createElement('div');
hEl.dataset.handle = pos;
hEl.style.cssText = `position: absolute; width: 10px; height: 10px; background: #fff; border: 1px solid #333; box-sizing: border-box; cursor: ${HANDLE_CURSORS[pos]}; z-index: 2;`;
selDiv.appendChild(hEl);
handleEls[pos] = hEl;
});
function positionHandles() {
const W = parseFloat(selDiv.style.width);
const H = parseFloat(selDiv.style.height);
const half = 5;
const positions = {
nw: [-half, -half], n: [W / 2 - half, -half], ne: [W - half, -half],
e: [W - half, H / 2 - half],
se: [W - half, H - half], s: [W / 2 - half, H - half], sw: [-half, H - half],
w: [-half, H / 2 - half]
};
HANDLES.forEach(p => {
handleEls[p].style.left = positions[p][0] + 'px';
handleEls[p].style.top = positions[p][1] + 'px';
});
}
function canvasPos(e) {
const rect = canvas.getBoundingClientRect();
return {
x: Math.max(0, Math.min(e.clientX - rect.left, dispW)),
y: Math.max(0, Math.min(e.clientY - rect.top, dispH))
};
}
function updateSelDiv(dx, dy, dw, dh) {
const r = canvas.getBoundingClientRect();
selDiv.style.left = (r.left + dx) + 'px';
selDiv.style.top = (r.top + dy) + 'px';
selDiv.style.width = dw + 'px';
selDiv.style.height = dh + 'px';
selDiv.style.display = dw > 2 && dh > 2 ? 'block' : 'none';
if (dw > 2 && dh > 2) positionHandles();
}
function commitSel(dx, dy, dw, dh) {
dx = Math.max(0, Math.min(dx, dispW - dw));
dy = Math.max(0, Math.min(dy, dispH - dh));
dw = Math.min(dw, dispW - dx);
dh = Math.min(dh, dispH - dy);
selDisplay = { x: dx, y: dy, w: dw, h: dh };
updateSelDiv(dx, dy, dw, dh);
if (dw > 4 && dh > 4) {
// 画布 CSS px → 原图像素(同时考虑 baseScale 与 viewZoom,以及平移偏移)
const imgX = Math.round((dx - panX) / (baseScale * viewZoom));
const imgY = Math.round((dy - panY) / (baseScale * viewZoom));
const imgW = Math.round(dw / (baseScale * viewZoom));
const imgH = Math.round(dh / (baseScale * viewZoom));
cropRect = {
x: Math.max(0, imgX),
y: Math.max(0, imgY),
w: Math.min(naturalW - Math.max(0, imgX), imgW),
h: Math.min(naturalH - Math.max(0, imgY), imgH)
};
confirmBtn.style.opacity = '1';
confirmBtn.style.pointerEvents = 'auto';
} else {
cropRect = null;
selDiv.style.display = 'none';
confirmBtn.style.opacity = '0.4';
confirmBtn.style.pointerEvents = 'none';
}
}
// ─── 拖动状态 ─────────────────────────────────────────────
let startX, startY;
let dragMode = null; // null | 'draw' | 'move' | 'pan' | handle-key
let dragStart = null;
// 选区移动
selDiv.addEventListener('mousedown', (e) => {
if (e.target.dataset.handle) return;
e.stopPropagation(); e.preventDefault();
dragMode = 'move';
dragStart = { clientX: e.clientX, clientY: e.clientY, ...selDisplay };
});
// 手柄缩放
selDiv.addEventListener('mousedown', (e) => {
const h = e.target.dataset.handle;
if (!h) return;
e.stopPropagation(); e.preventDefault();
dragMode = h;
dragStart = { clientX: e.clientX, clientY: e.clientY, ...selDisplay };
});
// 画布:新建选区 或 中键平移
canvas.addEventListener('mousedown', (e) => {
if (e.button === 1) {
// 中键平移
e.preventDefault();
dragMode = 'pan';
dragStart = { clientX: e.clientX, clientY: e.clientY, panX, panY };
canvas.style.cursor = 'grabbing';
return;
}
if (e.button !== 0) return;
e.stopPropagation();
const pos = canvasPos(e);
startX = pos.x; startY = pos.y;
dragMode = 'draw';
cropRect = null; selDisplay = null;
selDiv.style.display = 'none';
confirmBtn.style.opacity = '0.4';
confirmBtn.style.pointerEvents = 'none';
});
overlay.addEventListener('mousemove', (e) => {
if (!dragMode) return;
e.preventDefault();
if (dragMode === 'pan') {
panX = dragStart.panX + (e.clientX - dragStart.clientX);
panY = dragStart.panY + (e.clientY - dragStart.clientY);
clampPan();
redrawCanvas();
return;
}
if (dragMode === 'draw') {
const pos = canvasPos(e);
updateSelDiv(
Math.min(startX, pos.x), Math.min(startY, pos.y),
Math.abs(pos.x - startX), Math.abs(pos.y - startY)
);
return;
}
const ddx = e.clientX - dragStart.clientX;
const ddy = e.clientY - dragStart.clientY;
let { x, y, w, h } = dragStart;
if (dragMode === 'move') {
x += ddx; y += ddy;
} else {
if (dragMode.includes('n')) { y += ddy; h -= ddy; }
if (dragMode.includes('s')) { h += ddy; }
if (dragMode.includes('w')) { x += ddx; w -= ddx; }
if (dragMode.includes('e')) { w += ddx; }
if (w < 4) { if (dragMode.includes('w')) x = dragStart.x + dragStart.w - 4; w = 4; }
if (h < 4) { if (dragMode.includes('n')) y = dragStart.y + dragStart.h - 4; h = 4; }
}
x = Math.max(0, Math.min(x, dispW - w));
y = Math.max(0, Math.min(y, dispH - h));
w = Math.min(w, dispW - x);
h = Math.min(h, dispH - y);
updateSelDiv(x, y, w, h);
});
overlay.addEventListener('mouseup', (e) => {
if (!dragMode) return;
if (dragMode === 'pan') {
dragMode = null; dragStart = null;
canvas.style.cursor = viewZoom > 1 ? 'grab' : 'crosshair';
return;
}
if (dragMode === 'draw') {
const pos = canvasPos(e);
commitSel(
Math.min(startX, pos.x), Math.min(startY, pos.y),
Math.abs(pos.x - startX), Math.abs(pos.y - startY)
);
dragMode = null;
return;
}
// move / handle commit
const ddx = e.clientX - dragStart.clientX;
const ddy = e.clientY - dragStart.clientY;
let { x, y, w, h } = dragStart;
if (dragMode === 'move') {
x += ddx; y += ddy;
} else {
if (dragMode.includes('n')) { y += ddy; h -= ddy; }
if (dragMode.includes('s')) { h += ddy; }
if (dragMode.includes('w')) { x += ddx; w -= ddx; }
if (dragMode.includes('e')) { w += ddx; }
if (w < 4) { if (dragMode.includes('w')) x = dragStart.x + dragStart.w - 4; w = 4; }
if (h < 4) { if (dragMode.includes('n')) y = dragStart.y + dragStart.h - 4; h = 4; }
}
commitSel(x, y, w, h);
dragMode = null;
});
// ─── 确认 / 取消 ──────────────────────────────────────────
confirmBtn.addEventListener('click', async () => {
if (!cropRect) return;
confirmBtn.textContent = '发送中...';
confirmBtn.style.opacity = '0.6';
confirmBtn.style.pointerEvents = 'none';
try {
const cleanImg = await cleanImgPromise;
const cropCanvas = document.createElement('canvas');
cropCanvas.width = cropRect.w; cropCanvas.height = cropRect.h;
cropCanvas.getContext('2d').drawImage(
cleanImg, cropRect.x, cropRect.y, cropRect.w, cropRect.h,
0, 0, cropRect.w, cropRect.h
);
const base64 = cropCanvas.toDataURL('image/png');
await sendBase64ToEagle(base64, imageUrl);
confirmBtn.textContent = '✓ 已发送!';
setTimeout(() => overlay.remove(), 1200);
} catch (err) {
alert('发送到 Eagle 失败:\n' + err.message);
confirmBtn.textContent = '✓ 裁剪并发送到 Eagle';
confirmBtn.style.opacity = '1';
confirmBtn.style.pointerEvents = 'auto';
}
});
cancelBtn.addEventListener('click', () => overlay.remove());
} // end loadImage
fancyboxRoot.appendChild(overlay);
// ESC 关闭
const escHandler = (e) => {
if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', escHandler); }
};
document.addEventListener('keydown', escHandler);
overlay.addEventListener('remove', () => document.removeEventListener('keydown', escHandler));
}
// ==================== 获取当前图片 URL ====================
function getCurrentImageUrl() {
const selectors = [
'.f-carousel__slide.is-selected img',
'.fancybox__slide.is-selected img',
'.fancybox__slide.has-image img',
'.f-carousel__slide.has-image img'
];
for (const selector of selectors) {
const img = document.querySelector(selector);
if (img) {
const url = img.src || img.dataset.src || img.dataset.lazySrc;
if (url) { console.log('[Eagle] 通过选择器找到图片:', selector); return url; }
}
}
const fancyboxImgs = document.querySelectorAll('.fancybox__container img, .f-carousel img');
for (const img of fancyboxImgs) {
const url = img.src || img.dataset.src || img.dataset.lazySrc;
if (url && !url.includes('data:image')) { console.log('[Eagle] 通过容器找到图片'); return url; }
}
if (window.Fancybox) {
try {
const instance = window.Fancybox.getInstance();
if (instance && instance.getSlide) {
const slide = instance.getSlide();
const url = slide?.src || slide?.thumb;
if (url) { console.log('[Eagle] 通过 Fancybox API 找到图片'); return url; }
}
} catch (err) { console.log('[Eagle] Fancybox API 不可用'); }
}
return null;
}
// ==================== 按钮注入 ====================
function addSendButton() {
const toolbar = document.querySelector('.f-carousel__toolbar .is-middle, .f-carousel__toolbar__column.is-middle');
if (toolbar && !toolbar.querySelector('.eagle-send-button')) {
console.log('[Eagle] 添加发送按钮');
// ---- 发送按钮 ----
const button = document.createElement('button');
button.className = 'f-button eagle-send-button';
button.title = '发送到 Eagle';
button.innerHTML = `
`;
button.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const imageUrl = getCurrentImageUrl();
if (!imageUrl) {
console.error('[Eagle] 无法获取图片URL,请检查页面结构');
alert('无法获取图片URL\n请在控制台查看详细信息');
console.log('[Eagle] 调试信息:');
console.log('- Fancybox容器:', document.querySelector('.fancybox__container'));
console.log('- 所有图片:', document.querySelectorAll('.fancybox__container img'));
return;
}
console.log('[Eagle] 最终图片URL:', imageUrl);
try {
button.disabled = true;
button.style.opacity = '0.5';
button.title = '正在发送...';
await sendToEagle(imageUrl);
button.title = '✓ 已发送!';
button.style.opacity = '1';
setTimeout(() => {
button.disabled = false;
button.title = '发送到 Eagle';
}, 1500);
} catch (error) {
console.error('[Eagle] 发送失败:', error);
alert('发送到 Eagle 失败:\n' + error.message);
button.disabled = false;
button.style.opacity = '1';
button.title = '发送到 Eagle';
}
});
toolbar.appendChild(button);
// ---- 裁剪按钮 ----
const cropButton = document.createElement('button');
cropButton.className = 'f-button eagle-crop-button';
cropButton.title = '裁剪后发送到 Eagle';
cropButton.innerHTML = `
`;
cropButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const imageUrl = getCurrentImageUrl();
if (!imageUrl) {
alert('无法获取图片URL');
return;
}
// 把页面上已加载的 img 元素一起传入,用于即时渲染
const pageImg = document.querySelector(
'.f-carousel__slide.is-selected img, .fancybox__slide.is-selected img, .fancybox__slide.has-image img, .f-carousel__slide.has-image img'
);
openCropUI(imageUrl, pageImg && pageImg.complete && pageImg.naturalWidth > 0 ? pageImg : null);
});
toolbar.appendChild(cropButton);
console.log('[Eagle] 按钮已添加');
}
}
// ==================== 监听 DOM ====================
const observer = new MutationObserver(() => {
const hasFancybox = document.querySelector('.fancybox__container, .f-carousel__toolbar');
const hasButton = document.querySelector('.eagle-send-button');
if (hasFancybox && !hasButton) {
setTimeout(addSendButton, 100);
setTimeout(addSendButton, 500);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
setTimeout(addSendButton, 1000);
console.log('[Eagle] 监听器已启动');
// 注册菜单命令
GM_registerMenuCommand('Eagle 收图设置', openSettings);
})();