在右下角添加悬浮球,点击对应图床按钮弹出上传表单对话框(目前支持 imgURL/SMMS 图床)
// ==UserScript==
// @name 图床上传脚本
// @namespace http://21zys.com/
// @version 1.3.7
// @description 在右下角添加悬浮球,点击对应图床按钮弹出上传表单对话框(目前支持 imgURL/SMMS 图床)
// @match *://*/*
// @author 21zys (Optimized)
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @require https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.13/dayjs.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/uuid/8.3.2/uuid.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js
// @connect sm.ms
// @connect smms.app
// @connect imgurl.org
// @license MIT
// ==/UserScript==
(function() {
'use strict';
if (window !== window.top) return;
// --- 工具函数:DOM 创建与样式设置 ---
function createEl(tag, styles = {}, props = {}, parent = null) {
const el = document.createElement(tag);
Object.assign(el.style, styles);
for (const key in props) {
if (key === 'dataset') {
Object.assign(el.dataset, props[key]);
} else {
el[key] = props[key];
}
}
if (parent) parent.appendChild(el);
return el;
}
// --- 工具函数:通用拖拽逻辑 ---
// 新增 restrictToEdge 参数,默认为 true。悬浮球传入 false 以允许全区域拖拽。
function makeDraggable(element, storageKey, handle = null, restrictToEdge = true) {
const target = handle || element;
let isDragging = false, startX, startY;
target.addEventListener('mousedown', (e) => {
if (handle && e.target !== handle) return;
// 边缘检测逻辑:如果 restrictToEdge 为 true,且点击位置不在边缘,则不开始拖拽
if (!handle && restrictToEdge) {
const rect = element.getBoundingClientRect();
const edgeThreshold = 25;
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
if (offsetX > edgeThreshold && offsetX < element.clientWidth - edgeThreshold &&
offsetY > edgeThreshold && offsetY < element.clientHeight - edgeThreshold) {
return;
}
}
startX = e.clientX;
startY = e.clientY;
const rect = element.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
const onMouseMove = (e) => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!isDragging && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
isDragging = true;
}
if (isDragging) {
element.style.left = (e.clientX - offsetX) + 'px';
element.style.top = (e.clientY - offsetY) + 'px';
element.style.right = 'auto';
element.style.bottom = 'auto';
element.style.transform = 'none';
if (storageKey) {
localStorage.setItem(storageKey, JSON.stringify({
left: element.style.left,
top: element.style.top
}));
}
}
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
setTimeout(() => isDragging = false, 100);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
}
// --- 工具函数:通用对话框 UI 构建 ---
function createBaseDialog(storageKey) {
const savedPos = JSON.parse(localStorage.getItem(storageKey)) || null;
const dialogStyle = {
position: 'fixed', width: '400px', padding: '20px',
backgroundColor: 'rgba(255, 255, 255, 0.8)', borderRadius: '12px',
backdropFilter: 'blur(10px)', boxShadow: '0 8px 32px rgba(0, 0, 0, 0.37)',
display: 'none', opacity: '0', zIndex: '999999', fontFamily: 'Arial, sans-serif',
transition: 'opacity 0.3s ease',
left: savedPos ? savedPos.left : '50%',
top: savedPos ? savedPos.top : '50%',
transform: savedPos ? 'none' : 'translate(-50%, -50%)'
};
const dialog = createEl('div', dialogStyle, {}, document.body);
// 对话框保持默认行为:只有边缘可以拖拽
makeDraggable(dialog, storageKey);
// 关闭按钮
createEl('span', {
position: 'absolute', top: '10px', right: '10px', cursor: 'pointer',
fontSize: '20px', color: '#333'
}, { innerHTML: '×', onclick: () => closeDialog(dialog) }, dialog);
return dialog;
}
// --- 工具函数:通用表单控件 ---
const commonStyles = {
label: { fontWeight: 'bold', color: '#333', display: 'inline-block' },
input: { padding: '8px', border: '1px solid #ccc', borderRadius: '4px', width: 'auto' },
btn: { padding: '8px 16px', border: 'none', borderRadius: '4px', cursor: 'pointer', transition: '0.3s' }
};
function createInputRow(form, labelText, inputName, value = '', placeholder = '', type = 'text') {
createEl('label', commonStyles.label, { innerText: labelText }, form);
return createEl('input', commonStyles.input, {
type: type, name: inputName, value: value, placeholder: placeholder
}, form);
}
// --- 核心逻辑 ---
const savedPosition = JSON.parse(localStorage.getItem('floatingBallPosition')) || { right: '20px', bottom: '20px' };
const containerStyle = {
position: 'fixed', right: savedPosition.right, bottom: savedPosition.bottom,
left: savedPosition.left || 'auto', top: savedPosition.top || 'auto',
padding: '25px', zIndex: '99999'
};
const floatingContainer = createEl('div', containerStyle, {}, document.body);
// 修复:悬浮球传入 false,禁用边缘检测,允许点击任意位置拖拽
makeDraggable(floatingContainer, 'floatingBallPosition', null, false);
// 悬浮球
const ballStyle = {
width: '50px', height: '50px', borderRadius: '50%', backgroundColor: '#007bff',
color: '#fff', textAlign: 'center', lineHeight: '50px', cursor: 'pointer',
fontSize: '24px', userSelect: 'none', backdropFilter: 'blur(6px)',
boxShadow: 'rgba(90, 90, 90, 1) 2px 2px 9px 0px'
};
const floatingBall = createEl('div', ballStyle, { innerHTML: '+' }, floatingContainer);
// 子按钮生成器
const createSubBtn = (iconUrl, left, onClick) => {
const btn = createEl('div', {
position: 'relative', bottom: '90px', left: left, width: '50px', height: '50px',
background: `url('${iconUrl}') no-repeat center center`, backgroundSize: '50% 50%',
backgroundColor: 'rgba(0,0,0,0)', cursor: 'pointer', display: 'none', zIndex: '99999'
}, {}, floatingBall);
btn.addEventListener('click', onClick);
return btn;
};
const imgUrlBtn = createSubBtn('https://www.imgurl.org/favicon.ico', '35px', () => openDialog(initImgUrlDialog()));
const smmsBtn = createSubBtn('https://smms.app/favicon-32x32.png', '50px', () => openDialog(initSmmsDialog()));
floatingContainer.addEventListener('mouseenter', () => { imgUrlBtn.style.display = 'block'; smmsBtn.style.display = 'block'; });
floatingContainer.addEventListener('mouseleave', () => { imgUrlBtn.style.display = 'none'; smmsBtn.style.display = 'none'; });
// 缓存 Dialog 实例,避免重复创建
let _imgUrlDialogInstance = null;
let _smmsDialogInstance = null;
function openDialog(dialog) {
dialog.style.display = 'block';
setTimeout(() => dialog.style.opacity = '1', 10);
}
function closeDialog(dialog) {
dialog.style.opacity = '0';
setTimeout(() => dialog.style.display = 'none', 300);
}
// --- 结果回显与 Tab 管理通用逻辑 ---
function setupResultArea(dialog, initialTab, onTabChange) {
const tabContainer = createEl('div', { display: 'flex', justifyContent: 'space-around', marginTop: '10px', marginBottom: '3px' }, {}, dialog);
const resultContainer = createEl('div', { marginTop: '10px', textAlign: 'center' }, {}, dialog);
const resultInput = createEl('input', {
width: '100%', padding: '8px', border: '1px solid #ccc', borderRadius: '4px', cursor: 'pointer'
}, { type: 'text', readOnly: true, placeholder: '上传结果' }, resultContainer);
resultInput.addEventListener('click', () => GM_setClipboard(resultInput.value));
let currentTab = initialTab;
const tabs = ['HTML', 'imgURL', 'MarkDown', 'BBCode'];
const btns = [];
const updateResultFormat = () => {
const url = resultInput.dataset.url;
if (!url) return;
let text = '';
switch (currentTab) {
case 'HTML': text = `<img src="${url}" alt="image">`; break;
case 'imgURL': text = url; break;
case 'MarkDown': text = ``; break;
case 'BBCode': text = `[URL=${url}][IMG]${url}[/IMG][/URL]`; break; // 注意:这里统一了 imgURL 和 smms 的 BBCode 格式差异,以 SMMS 的完整格式为准
}
if (resultInput.value !== text) resultInput.value = text;
};
tabs.forEach(tab => {
const btn = createEl('button', {
flex: '1', padding: '10px', border: '1px solid #ccc', borderRadius: '4px 4px 0 0',
cursor: 'pointer', backgroundColor: tab === currentTab ? '#007bff' : '#f8f9fa',
color: tab === currentTab ? '#fff' : '#333'
}, { textContent: tab }, tabContainer);
btn.addEventListener('click', () => {
currentTab = tab;
onTabChange(tab);
btns.forEach(b => {
const active = b.textContent === tab;
Object.assign(b.style, { backgroundColor: active ? '#007bff' : '#f8f9fa', color: active ? '#fff' : '#333' });
});
updateResultFormat();
});
btns.push(btn);
});
return { resultInput, updateResultFormat };
}
function createProgress() {
const container = createEl('div', { marginTop: '10px', display: 'none', width: '100%' });
const bar = createEl('progress', { width: '100%', height: '20px' }, { value: 0, max: 100 }, container);
return { container, bar };
}
// --- imgURL Dialog ---
function initImgUrlDialog() {
if (_imgUrlDialogInstance) return _imgUrlDialogInstance;
const STORAGE_KEY = 'globalImgUrlUploadData';
const globalData = JSON.parse(GM_getValue(STORAGE_KEY) || localStorage.getItem(STORAGE_KEY)) || {
uid: '您的UID', token: '您的Token', uploadDate: dayjs().format('YYYY-MM-DD'),
uploadCount: 0, selectedTab: 'imgURL', selectedAlbumId: 'default', albumList: []
};
if (globalData.uploadDate !== dayjs().format('YYYY-MM-DD')) {
globalData.uploadDate = dayjs().format('YYYY-MM-DD');
globalData.uploadCount = 0;
saveData();
}
function saveData() {
GM_setValue(STORAGE_KEY, JSON.stringify(globalData));
localStorage.setItem(STORAGE_KEY, JSON.stringify(globalData));
}
const dialog = createBaseDialog('imgUrlDialogPosition');
const form = createEl('form', { display: 'grid', gap: '10px' }, { method: 'post' }, dialog);
const uidInput = createInputRow(form, 'UID:', 'uid', globalData.uid);
const tokenInput = createInputRow(form, 'Token:', 'token', globalData.token);
// 相册部分
const albumContainer = createEl('div', { marginTop: '10px', textAlign: 'center' }, {}, dialog);
createEl('button', { ...commonStyles.btn, backgroundColor: '#f8f9fa', border: '1px solid #ccc' },
{ type: 'button', innerText: '刷新相册', onclick: fetchAlbums }, albumContainer);
const albumSelect = createEl('select', {
width: '120px', height: '34px', marginTop: '-3px', marginRight: '3px',
textAlign: 'center', border: '1px solid #ccc', borderRadius: '4px'
}, {}, albumContainer);
const waterInput = createEl('input', {
width: '120px', height: '34px', marginTop: '-3px', textAlign: 'left',
border: '1px solid #ccc', borderRadius: '4px'
}, { type: 'text', placeholder: '请输入水印文本', value: globalData.water || '' }, albumContainer);
albumSelect.addEventListener('change', () => { globalData.selectedAlbumId = albumSelect.value; saveData(); });
function loadAlbumList() {
albumSelect.innerHTML = '';
createEl('option', {}, { value: 'default', textContent: '默认相册' }, albumSelect);
globalData.albumList.forEach(album => {
createEl('option', {}, { value: album.album_id, textContent: album.name }, albumSelect);
});
albumSelect.value = globalData.selectedAlbumId;
}
loadAlbumList();
function fetchAlbums() {
const fd = new FormData();
fd.append('uid', uidInput.value);
fd.append('token', tokenInput.value);
GM_xmlhttpRequest({
method: 'POST', url: 'https://www.imgurl.org/api/v2/albums', data: fd,
onload: (res) => {
const data = JSON.parse(res.responseText);
if (data?.data?.length) {
globalData.albumList = data.data;
saveData();
loadAlbumList();
} else albumSelect.innerHTML = '<option>未找到相册</option>';
},
onerror: () => albumSelect.innerHTML = '<option>获取失败</option>'
});
}
// 文件选择与按钮
createEl('label', commonStyles.label, { innerText: '选择文件:' }, form);
const fileInput = createEl('input', commonStyles.input, { type: 'file', name: 'file' }, form);
const btnContainer = createEl('div', { marginTop: '10px', textAlign: 'right' }, {}, form);
const countLabel = createEl('label', { fontSize: '1rem', fontWeight: 'bold', marginRight: '10px' },
{ textContent: `今日已上传 ${globalData.uploadCount} 张图片` }, btnContainer);
const uploadBtn = createEl('input', { ...commonStyles.btn, backgroundColor: '#007bff', color: '#fff', marginRight: '10px' },
{ type: 'submit', value: '开始上传' }, btnContainer);
const clearBtn = createEl('input', { ...commonStyles.btn, backgroundColor: '#6c757d', color: '#fff' },
{ type: 'button', value: '清空' }, btnContainer);
// 进度条
const { container: progressContainer, bar: progressBar } = createProgress();
dialog.appendChild(progressContainer);
// 结果与Tab
const { resultInput, updateResultFormat } = setupResultArea(dialog, globalData.selectedTab, (tab) => {
globalData.selectedTab = tab; saveData();
});
clearBtn.onclick = () => {
fileInput.value = ''; resultInput.value = ''; resultInput.style.color = ''; delete resultInput.dataset.url;
};
form.onsubmit = (e) => {
e.preventDefault();
if (!uidInput.value || !tokenInput.value) return alertResult('请填写UID和Token', 'red');
globalData.uid = uidInput.value.trim();
globalData.token = tokenInput.value.trim();
globalData.water = waterInput.value.trim();
saveData();
if (!fileInput.files.length) return alertResult('请选择文件', 'red');
const file = fileInput.files[0];
if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(file.name.split('.').pop().toLowerCase())) {
return alertResult('格式不支持', 'red');
}
addWatermark(file, globalData.water, (blob) => {
progressContainer.style.display = 'block';
const fd = new FormData();
fd.append('file', blob, file.name);
fd.append('uid', globalData.uid);
fd.append('token', globalData.token);
if (albumSelect.value !== 'default') fd.append('album_id', albumSelect.value);
GM_xmlhttpRequest({
method: 'POST',
url: 'https://www.imgurl.org/api/v2/upload',
data: fd,
upload: {
onprogress: (e) => { if(e.lengthComputable) progressBar.value = (e.loaded / e.total) * 100; }
},
onload: (res) => {
progressContainer.style.display = 'none';
try {
const data = JSON.parse(res.responseText);
if (data?.data?.url) {
handleSuccess(data.data.url);
} else {
alertResult('上传失败: ' + (data.msg || '未知错误'), 'red');
}
} catch (e) { alertResult('解析失败', 'red'); }
},
onerror: () => { progressContainer.style.display = 'none'; alertResult('网络错误', 'red'); }
});
});
};
function alertResult(msg, color) {
resultInput.value = msg;
resultInput.style.color = color;
}
function handleSuccess(url) {
resultInput.dataset.url = url;
updateResultFormat();
resultInput.style.color = 'green';
globalData.uploadCount++;
saveData();
countLabel.textContent = `今日已上传 ${globalData.uploadCount} 张图片`;
}
_imgUrlDialogInstance = dialog;
return dialog;
}
// --- SM.MS Dialog ---
function initSmmsDialog() {
if (_smmsDialogInstance) return _smmsDialogInstance;
const STORAGE_KEY = 'globalSmmsUploadData';
const globalData = JSON.parse(GM_getValue(STORAGE_KEY) || localStorage.getItem(STORAGE_KEY)) || {
token: '您的Token', uploadDate: dayjs().format('YYYY-MM-DD'), uploadCount: 0,
selectedTab: 'imgURL', water: '', renamePattern: '', autoIncrement: 0
};
if (globalData.uploadDate !== dayjs().format('YYYY-MM-DD')) {
globalData.uploadDate = dayjs().format('YYYY-MM-DD');
globalData.uploadCount = 0;
saveData();
}
function saveData() {
GM_setValue(STORAGE_KEY, JSON.stringify(globalData));
localStorage.setItem(STORAGE_KEY, JSON.stringify(globalData));
}
const dialog = createBaseDialog('smmsDialogPosition');
const form = createEl('form', { display: 'grid', gap: '10px' }, { method: 'post' }, dialog);
const waterInput = createInputRow(form, '文本水印:', 'smms-water', globalData.water, '请输入需要添加的文本水印');
const renameInput = createInputRow(form, '高级文件重命名:', 'smms-rename', globalData.renamePattern, '重命名格式(忽略请留空)');
const tokenInput = createInputRow(form, 'Token:', 'token', globalData.token);
createEl('label', commonStyles.label, { innerText: '选择文件:' }, form);
const fileInput = createEl('input', commonStyles.input, { type: 'file', name: 'file' }, form);
const btnContainer = createEl('div', { marginTop: '10px', textAlign: 'right' }, {}, form);
const countLabel = createEl('label', { fontSize: '1rem', fontWeight: 'bold', marginRight: '10px' },
{ textContent: `今日已上传 ${globalData.uploadCount} 张图片` }, btnContainer);
const uploadBtn = createEl('input', { ...commonStyles.btn, backgroundColor: '#007bff', color: '#fff', marginRight: '10px' },
{ type: 'submit', value: '开始上传' }, btnContainer);
const clearBtn = createEl('input', { ...commonStyles.btn, backgroundColor: '#6c757d', color: '#fff' },
{ type: 'button', value: '清空' }, btnContainer);
const { container: progressContainer, bar: progressBar } = createProgress();
dialog.appendChild(progressContainer);
const { resultInput, updateResultFormat } = setupResultArea(dialog, globalData.selectedTab, (tab) => {
globalData.selectedTab = tab; saveData();
});
clearBtn.onclick = () => {
fileInput.value = ''; resultInput.value = ''; resultInput.style.color = ''; delete resultInput.dataset.url;
};
form.onsubmit = (e) => {
e.preventDefault();
if (!tokenInput.value) return alertResult('请填写Token', 'red');
globalData.token = tokenInput.value.trim();
globalData.water = waterInput.value.trim();
globalData.renamePattern = renameInput.value.trim();
globalData.autoIncrement++;
saveData();
if (!fileInput.files.length) return alertResult('请选择文件', 'red');
const file = fileInput.files[0];
if (!['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(file.name.split('.').pop().toLowerCase())) {
return alertResult('格式不支持', 'red');
}
addWatermark(file, globalData.water, (blob) => {
progressContainer.style.display = 'block';
progressBar.value = 30;
const fd = new FormData();
fd.append('smfile', blob, superPictureRename(file.name, globalData.renamePattern, globalData.autoIncrement));
fd.append('format', 'json');
GM_xmlhttpRequest({
method: 'POST',
url: 'https://sm.ms/api/v2/upload',
headers: { 'Authorization': globalData.token },
data: fd,
upload: {
onprogress: (e) => { if(e.lengthComputable) progressBar.value = (e.loaded / e.total) * 100; }
},
onload: (res) => {
progressBar.value = 100;
progressContainer.style.display = 'none';
try {
const data = JSON.parse(res.responseText);
if (data.success && data.data?.url) {
handleSuccess(data.data.url);
} else if (data.code === 'image_repeated') {
handleSuccess(data.images); // SM.MS 重复上传会返回 images 字段包含 url
} else {
alertResult('上传失败: ' + (data.message || '未知错误'), 'red');
}
} catch (e) { alertResult('解析失败', 'red'); }
},
onerror: () => { progressContainer.style.display = 'none'; alertResult('网络错误', 'red'); }
});
});
};
function alertResult(msg, color) { resultInput.value = msg; resultInput.style.color = color; }
function handleSuccess(url) {
resultInput.dataset.url = url;
updateResultFormat();
resultInput.style.color = 'green';
globalData.uploadCount++;
saveData();
countLabel.textContent = `今日已上传 ${globalData.uploadCount} 张图片`;
}
_smmsDialogInstance = dialog;
return dialog;
}
// --- 业务辅助函数 ---
function addWatermark(file, text, callback) {
if (!text) return callback(file); // 如果没有水印文本,直接返回原文件
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.src = e.target.result;
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const fontSize = img.width * 0.1;
ctx.font = `${fontSize}px Arial`;
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(-Math.PI / 4);
ctx.fillText(text, 0, 0);
ctx.rotate(Math.PI / 4);
ctx.translate(-canvas.width / 2, -canvas.height / 2);
canvas.toBlob(callback, file.type);
};
};
reader.readAsDataURL(file);
}
function superPictureRename(filename, pattern, autoIncrement) {
if (!pattern) return filename;
const extIndex = filename.lastIndexOf('.');
const ext = extIndex > -1 ? filename.substring(extIndex) : '';
const baseFilename = extIndex > -1 ? filename.substring(0, extIndex) : filename;
const now = dayjs();
const randomString = (len) => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let res = '';
for (let i = 0; i < len; i++) res += chars.charAt(Math.floor(Math.random() * chars.length));
return res;
};
const formatted = pattern.replace(/{Y}/g, now.format('YYYY'))
.replace(/{y}/g, now.format('YY'))
.replace(/{m}/g, now.format('MM'))
.replace(/{d}/g, now.format('DD'))
.replace(/{h}/g, now.format('HH'))
.replace(/{i}/g, now.format('mm'))
.replace(/{s}/g, now.format('ss'))
.replace(/{ms}/g, now.format('SSS'))
.replace(/{timestamp}/g, now.valueOf())
.replace(/{md5}/g, CryptoJS.MD5(randomString(32)).toString())
.replace(/{md5-16}/g, CryptoJS.MD5(randomString(16)).toString().substring(0, 16))
.replace(/{uuid}/g, uuid.v4())
.replace(/{str-(\d+)}/g, (m, p1) => randomString(parseInt(p1)))
.replace(/{filename}/g, baseFilename)
.replace(/{auto}/g, autoIncrement);
return formatted + ext;
}
})();