// ==UserScript==
// @name Image Format Saver (Custom Right-Click Menu)
// @namespace kien.tools
// @version 3.6
// @description Right-click ảnh -> Mở tab mới | Copy ảnh/link | Lưu định dạng (PNG/JPG/WEBP/AVIF) | Chỉnh sửa ảnh, tìm kiếm ảnh (JPG->Google, còn lại->Yandex, tự bóc proxy + check Content-Type thật); dialog chọn nơi lưu, whitelist domain và chất lượng ảnh qua menu Tampermonkey
// @author Kien
// @match *://*/*
// @grant GM_download
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// @connect *
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// Tampermonkey chạy trong sandbox riêng, nhiều API của trình duyệt (showSaveFilePicker,
// Clipboard API, ClipboardItem...) không được proxy qua window của sandbox dù trình duyệt
// có hỗ trợ. Dùng unsafeWindow (window thật của trang) để truy cập trực tiếp các API này.
const REAL_WINDOW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
// ================== CẤU HÌNH DOMAIN WHITELIST ==================
const allowedDomains = GM_getValue('allowedDomains', []);
function isDomainAllowed() {
if (!allowedDomains.length) return true; // để trống = cho phép tất cả
const host = location.hostname;
return allowedDomains.some((pattern) => {
pattern = (pattern || '').trim();
if (!pattern) return false;
if (pattern.startsWith('*.')) {
const base = pattern.slice(2);
return host === base || host.endsWith('.' + base);
}
return host === pattern || host.endsWith('.' + pattern);
});
}
function configureDomains() {
const current = GM_getValue('allowedDomains', []);
const overlay = document.createElement('div');
Object.assign(overlay.style, {
position: 'fixed', inset: '0', zIndex: 2147483647,
background: 'rgba(0,0,0,.45)', display: 'flex',
alignItems: 'flex-start', justifyContent: 'center',
paddingTop: '5vh', fontFamily: 'system-ui, sans-serif',
});
const panel = document.createElement('div');
Object.assign(panel.style, {
background: '#fff', color: '#222', width: '620px', maxWidth: '92vw',
maxHeight: '85vh', borderRadius: '8px', boxShadow: '0 10px 40px rgba(0,0,0,.35)',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
});
const header = document.createElement('div');
header.textContent = '⚙️ Cấu hình domain cho phép chạy script';
Object.assign(header.style, {
padding: '16px 20px', fontSize: '17px', fontWeight: '600',
borderBottom: '1px solid #eee',
});
const subLabel = document.createElement('div');
subLabel.textContent = 'Domain được phép chạy script (hỗ trợ wildcard *. ở đầu, vd: *.example.com). Để trống toàn bộ = chạy trên mọi trang:';
Object.assign(subLabel.style, { padding: '12px 20px 4px', fontSize: '13px', color: '#555' });
const listWrap = document.createElement('div');
Object.assign(listWrap.style, { overflowY: 'auto', padding: '8px 20px', flex: '1' });
const table = document.createElement('div');
table.style.width = '100%';
function makeRow(index, value) {
const row = document.createElement('div');
Object.assign(row.style, {
display: 'flex', alignItems: 'center', gap: '10px',
padding: '6px 0', borderBottom: '1px solid #f0f0f0',
});
const num = document.createElement('div');
num.textContent = String(index);
Object.assign(num.style, {
width: '28px', textAlign: 'center', color: '#888', fontSize: '13px', flexShrink: '0',
});
const input = document.createElement('input');
input.type = 'text';
input.value = value || '';
input.placeholder = 'vd: example.com hoặc *.example.com';
Object.assign(input.style, {
flex: '1', padding: '7px 10px', border: '1px solid #ccc',
borderRadius: '5px', fontSize: '14px',
});
const removeBtn = document.createElement('button');
removeBtn.textContent = '✕';
Object.assign(removeBtn.style, {
border: 'none', background: 'transparent', color: '#c33',
cursor: 'pointer', fontSize: '15px', flexShrink: '0', padding: '4px 6px',
});
removeBtn.addEventListener('click', () => {
row.remove();
renumberRows();
});
row.appendChild(num);
row.appendChild(input);
row.appendChild(removeBtn);
return row;
}
function renumberRows() {
[...table.children].forEach((row, i) => {
row.firstChild.textContent = String(i + 1);
});
}
(current.length ? current : ['']).forEach((d, i) => table.appendChild(makeRow(i + 1, d)));
listWrap.appendChild(table);
const addBtn = document.createElement('button');
addBtn.textContent = '+ Thêm domain';
Object.assign(addBtn.style, {
margin: '10px 20px 0', alignSelf: 'flex-start', border: '1px dashed #999',
background: 'transparent', borderRadius: '5px', padding: '6px 12px',
cursor: 'pointer', fontSize: '13px', color: '#444',
});
addBtn.addEventListener('click', () => {
table.appendChild(makeRow(table.children.length + 1, ''));
listWrap.scrollTop = listWrap.scrollHeight;
});
const footer = document.createElement('div');
Object.assign(footer.style, {
display: 'flex', justifyContent: 'flex-end', gap: '10px',
padding: '14px 20px', borderTop: '1px solid #eee',
});
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Hủy';
Object.assign(cancelBtn.style, {
padding: '8px 16px', borderRadius: '5px', border: '1px solid #ccc',
background: '#f5f5f5', cursor: 'pointer', fontSize: '14px',
});
cancelBtn.addEventListener('click', () => overlay.remove());
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Lưu';
Object.assign(saveBtn.style, {
padding: '8px 16px', borderRadius: '5px', border: 'none',
background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: '600',
});
saveBtn.addEventListener('click', () => {
const domains = [...table.querySelectorAll('input')]
.map((inp) => inp.value.trim())
.filter(Boolean);
GM_setValue('allowedDomains', domains);
overlay.remove();
alert(
'Đã lưu danh sách domain. Vui lòng RELOAD lại trang để áp dụng.\n\n' +
(domains.length ? domains.join('\n') : '(cho phép chạy trên mọi trang)')
);
});
footer.appendChild(cancelBtn);
footer.appendChild(saveBtn);
panel.appendChild(header);
panel.appendChild(subLabel);
panel.appendChild(listWrap);
panel.appendChild(addBtn);
panel.appendChild(footer);
overlay.appendChild(panel);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
document.addEventListener(
'keydown',
function escClose(e) {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', escClose);
}
}
);
document.body.appendChild(overlay);
}
GM_registerMenuCommand('⚙️ Cấu hình domain cho phép chạy script', configureDomains);
// ================== CẤU HÌNH CHẤT LƯỢNG ẢNH (%) ==================
const DEFAULT_QUALITY = { jpg: 92, webp: 92, avif: 80 };
function getQualityMap() {
const stored = GM_getValue('formatQuality', {});
return Object.assign({}, DEFAULT_QUALITY, stored);
}
// Trả về quality dạng 0-1 để truyền vào canvas.toBlob(). PNG không dùng quality.
function getQuality(ext) {
const map = getQualityMap();
const val = map[ext];
const percent = typeof val === 'number' && val >= 1 && val <= 100 ? val : DEFAULT_QUALITY[ext] || 90;
return percent / 100;
}
function configureQuality() {
const current = getQualityMap();
const editableFormats = [
{ ext: 'jpg', label: 'JPG' },
{ ext: 'webp', label: 'WEBP' },
{ ext: 'avif', label: 'AVIF' },
];
const overlay = document.createElement('div');
Object.assign(overlay.style, {
position: 'fixed', inset: '0', zIndex: 2147483647,
background: 'rgba(0,0,0,.45)', display: 'flex',
alignItems: 'flex-start', justifyContent: 'center',
paddingTop: '5vh', fontFamily: 'system-ui, sans-serif',
});
const panel = document.createElement('div');
Object.assign(panel.style, {
background: '#fff', color: '#222', width: '460px', maxWidth: '92vw',
borderRadius: '8px', boxShadow: '0 10px 40px rgba(0,0,0,.35)',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
});
const header = document.createElement('div');
header.textContent = '🎚️ Cấu hình chất lượng ảnh khi lưu';
Object.assign(header.style, {
padding: '16px 20px', fontSize: '17px', fontWeight: '600',
borderBottom: '1px solid #eee',
});
const subLabel = document.createElement('div');
subLabel.textContent = 'PNG luôn là lossless nên không có chất lượng để chỉnh. Kéo thanh trượt hoặc gõ số (1-100):';
Object.assign(subLabel.style, { padding: '12px 20px 4px', fontSize: '13px', color: '#555' });
const body = document.createElement('div');
Object.assign(body.style, { padding: '8px 20px 4px' });
const numberInputs = {};
editableFormats.forEach(({ ext, label }) => {
const row = document.createElement('div');
Object.assign(row.style, {
display: 'flex', alignItems: 'center', gap: '12px',
padding: '10px 0', borderBottom: '1px solid #f0f0f0',
});
const nameEl = document.createElement('div');
nameEl.textContent = label;
Object.assign(nameEl.style, { width: '52px', fontWeight: '600', fontSize: '14px', flexShrink: '0' });
const range = document.createElement('input');
range.type = 'range';
range.min = '1';
range.max = '100';
range.value = String(current[ext] ?? DEFAULT_QUALITY[ext]);
Object.assign(range.style, { flex: '1' });
const number = document.createElement('input');
number.type = 'number';
number.min = '1';
number.max = '100';
number.value = String(current[ext] ?? DEFAULT_QUALITY[ext]);
Object.assign(number.style, {
width: '56px', padding: '5px 6px', border: '1px solid #ccc',
borderRadius: '5px', fontSize: '14px', textAlign: 'center', flexShrink: '0',
});
const percentEl = document.createElement('span');
percentEl.textContent = '%';
Object.assign(percentEl.style, { fontSize: '13px', color: '#666', flexShrink: '0' });
range.addEventListener('input', () => (number.value = range.value));
number.addEventListener('input', () => {
let v = parseInt(number.value, 10);
if (isNaN(v)) return;
v = Math.min(100, Math.max(1, v));
range.value = String(v);
});
numberInputs[ext] = number;
row.appendChild(nameEl);
row.appendChild(range);
row.appendChild(number);
row.appendChild(percentEl);
body.appendChild(row);
});
const footer = document.createElement('div');
Object.assign(footer.style, {
display: 'flex', justifyContent: 'flex-end', gap: '10px',
padding: '14px 20px', borderTop: '1px solid #eee',
});
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Hủy';
Object.assign(cancelBtn.style, {
padding: '8px 16px', borderRadius: '5px', border: '1px solid #ccc',
background: '#f5f5f5', cursor: 'pointer', fontSize: '14px',
});
cancelBtn.addEventListener('click', () => overlay.remove());
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Lưu';
Object.assign(saveBtn.style, {
padding: '8px 16px', borderRadius: '5px', border: 'none',
background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: '600',
});
saveBtn.addEventListener('click', () => {
const newMap = {};
editableFormats.forEach(({ ext }) => {
let v = parseInt(numberInputs[ext].value, 10);
if (isNaN(v)) v = DEFAULT_QUALITY[ext];
newMap[ext] = Math.min(100, Math.max(1, v));
});
GM_setValue('formatQuality', newMap);
overlay.remove();
});
footer.appendChild(cancelBtn);
footer.appendChild(saveBtn);
panel.appendChild(header);
panel.appendChild(subLabel);
panel.appendChild(body);
panel.appendChild(footer);
overlay.appendChild(panel);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
document.addEventListener('keydown', function escClose(e) {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', escClose);
}
});
document.body.appendChild(overlay);
}
GM_registerMenuCommand('🎚️ Cấu hình chất lượng ảnh khi lưu', configureQuality);
if (!isDomainAllowed()) {
return; // domain hiện tại không nằm trong whitelist -> không làm gì cả
}
// ================== FIX ẢNH ĐEN CHO CANVAS WEBGL ==================
// (vd: Real3DFlipBook / Three.js dùng preserveDrawingBuffer:false mặc định)
const _origGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, attrs) {
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
attrs = Object.assign({}, attrs, { preserveDrawingBuffer: true });
}
return _origGetContext.call(this, type, attrs);
};
// ================== DANH SÁCH ĐỊNH DẠNG ==================
const FORMATS = [
{ label: 'Lưu PNG', mime: 'image/png', ext: 'png' },
{ label: 'Lưu JPG', mime: 'image/jpeg', ext: 'jpg' },
{ label: 'Lưu WEBP', mime: 'image/webp', ext: 'webp' },
{ label: 'Lưu AVIF', mime: 'image/avif', ext: 'avif' },
];
let menuEl = null;
function removeMenu() {
if (menuEl) {
menuEl.remove();
menuEl = null;
}
document.removeEventListener('click', removeMenu, true);
document.removeEventListener('scroll', removeMenu, true);
document.removeEventListener('keydown', onKeyDown, true);
}
function onKeyDown(e) {
if (e.key === 'Escape') removeMenu();
}
// ================== DIALOG CHỌN NƠI LƯU (native "Save As") ==================
async function pickSaveHandle(filename, mime) {
const picker = REAL_WINDOW.showSaveFilePicker;
if (!picker) return null; // trình duyệt không hỗ trợ -> fallback
try {
const ext = filename.split('.').pop();
const handle = await picker.call(REAL_WINDOW, {
suggestedName: filename,
types: [{ description: 'Image file', accept: { [mime]: ['.' + ext] } }],
});
return handle;
} catch (err) {
if (err.name === 'AbortError') return 'cancelled';
console.warn('[ImageFormatSaver] showSaveFilePicker lỗi:', err);
return null;
}
}
async function writeBlob(blob, filename, handle) {
if (handle) {
try {
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
return;
} catch (err) {
console.warn('[ImageFormatSaver] Ghi file qua File System Access API lỗi, dùng fallback:', err);
}
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
function suggestedFileName(img, ext) {
let base = 'image';
try {
const u = new URL(img.src, location.href);
const last = u.pathname.split('/').pop();
if (last) base = last.replace(/\.[a-zA-Z0-9]+$/, '');
} catch (e) {}
return base + '.' + ext;
}
// ================== LƯU TỪ <img> ==================
function fallbackDownloadOriginal(img, handle) {
GM_xmlhttpRequest({
method: 'GET',
url: img.src,
responseType: 'blob',
onload: function (res) {
const blob = res.response;
let ext = 'jpg';
const m = img.src.match(/\.([a-zA-Z0-9]+)(?:\?|#|$)/);
if (m) ext = m[1];
writeBlob(blob, suggestedFileName(img, ext), handle);
},
onerror: function () {
alert('Không tải được ảnh gốc (lỗi mạng hoặc bị chặn).');
},
});
}
function saveImageAs(img, fmt, handle) {
if (!img) return;
const doConvert = (imageEl) => {
const canvas = document.createElement('canvas');
canvas.width = imageEl.naturalWidth || imageEl.width;
canvas.height = imageEl.naturalHeight || imageEl.height;
const ctx = canvas.getContext('2d');
try {
ctx.drawImage(imageEl, 0, 0, canvas.width, canvas.height);
} catch (err) {
console.warn('[ImageFormatSaver] drawImage lỗi:', err);
fallbackDownloadOriginal(img, handle);
return;
}
canvas.toBlob(
(blob) => {
if (!blob) {
console.warn('[ImageFormatSaver] toBlob null — canvas có thể bị taint (CORS).');
fallbackDownloadOriginal(img, handle);
return;
}
if (fmt.mime !== 'image/png' && blob.type !== fmt.mime) {
alert(
'Trình duyệt không hỗ trợ encode ' + fmt.ext.toUpperCase() + ' qua canvas. Thử định dạng khác (PNG luôn hỗ trợ).'
);
return;
}
writeBlob(blob, suggestedFileName(img, fmt.ext), handle);
},
fmt.mime,
getQuality(fmt.ext)
);
};
if (img.crossOrigin === 'anonymous' || img.src.startsWith(location.origin) || img.src.startsWith('data:')) {
doConvert(img);
return;
}
const testImg = new Image();
testImg.crossOrigin = 'anonymous';
testImg.onload = () => doConvert(testImg);
testImg.onerror = () => {
console.warn('[ImageFormatSaver] Load lại ảnh CORS thất bại, fallback tải gốc.');
fallbackDownloadOriginal(img, handle);
};
testImg.src = img.src;
}
// ================== LƯU TỪ <canvas> (vd: WebGL flipbook) ==================
function saveImageAsCanvas(canvas, fmt, handle) {
try {
canvas.toBlob(
(blob) => {
if (!blob) {
alert('Không tạo được ảnh từ canvas này (canvas rỗng hoặc bị taint).');
return;
}
if (fmt.mime !== 'image/png' && blob.type !== fmt.mime) {
alert('Trình duyệt không hỗ trợ encode ' + fmt.ext.toUpperCase() + ' từ canvas. Thử định dạng khác.');
return;
}
writeBlob(blob, 'image.' + fmt.ext, handle);
},
fmt.mime,
getQuality(fmt.ext)
);
} catch (err) {
console.warn('[ImageFormatSaver] Canvas tainted:', err);
alert(
'Canvas này chứa dữ liệu ảnh cross-origin nên trình duyệt chặn không cho đọc pixel để lưu. Không có cách lách giới hạn bảo mật này.'
);
}
}
// ================== COPY ẢNH / COPY LINK ẢNH ==================
function imageElementToBlob(img, mime, quality) {
return new Promise((resolve, reject) => {
const doConvert = (imageEl) => {
const canvas = document.createElement('canvas');
canvas.width = imageEl.naturalWidth || imageEl.width;
canvas.height = imageEl.naturalHeight || imageEl.height;
const ctx = canvas.getContext('2d');
try {
ctx.drawImage(imageEl, 0, 0, canvas.width, canvas.height);
} catch (err) {
reject(err);
return;
}
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error('toBlob trả về null'));
},
mime,
quality
);
};
if (img.crossOrigin === 'anonymous' || img.src.startsWith(location.origin) || img.src.startsWith('data:')) {
doConvert(img);
return;
}
const testImg = new Image();
testImg.crossOrigin = 'anonymous';
testImg.onload = () => doConvert(testImg);
testImg.onerror = (err) => reject(err);
testImg.src = img.src;
});
}
function imageElementToPngBlob(img) {
return imageElementToBlob(img, 'image/png');
}
function canvasToPngBlob(canvas) {
return new Promise((resolve, reject) => {
try {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error('toBlob trả về null'));
}, 'image/png');
} catch (err) {
reject(err);
}
});
}
async function copyBlobToClipboard(blob) {
const clipboard = REAL_WINDOW.navigator && REAL_WINDOW.navigator.clipboard;
const ClipboardItemCtor = REAL_WINDOW.ClipboardItem;
if (!clipboard || !clipboard.write || !ClipboardItemCtor) {
throw new Error('Trình duyệt không hỗ trợ Clipboard API để copy ảnh.');
}
await clipboard.write([new ClipboardItemCtor({ [blob.type]: blob })]);
}
async function copyImageToClipboard(img) {
try {
const blob = await imageElementToPngBlob(img);
await copyBlobToClipboard(blob);
} catch (err) {
console.warn('[ImageFormatSaver] Copy ảnh lỗi:', err);
alert(
'Không copy được ảnh vào clipboard. Có thể do ảnh bị chặn CORS (cross-origin) hoặc trình duyệt từ chối quyền clipboard.'
);
}
}
async function copyCanvasToClipboard(canvas) {
try {
const blob = await canvasToPngBlob(canvas);
await copyBlobToClipboard(blob);
} catch (err) {
console.warn('[ImageFormatSaver] Copy ảnh (canvas) lỗi:', err);
alert(
'Không copy được ảnh vào clipboard. Canvas có thể bị taint do dữ liệu cross-origin, hoặc trình duyệt từ chối quyền clipboard.'
);
}
}
async function copyImageLink(img) {
const url = img && img.src ? img.src : '';
if (!url) {
alert('Không tìm thấy link ảnh.');
return;
}
try {
const clipboard = REAL_WINDOW.navigator && REAL_WINDOW.navigator.clipboard;
if (!clipboard || !clipboard.writeText) throw new Error('Không có Clipboard API');
await clipboard.writeText(url);
} catch (err) {
console.warn('[ImageFormatSaver] Copy link ảnh lỗi:', err);
alert('Không copy được link ảnh vào clipboard.');
}
}
// ================== BÓC URL THẬT KHỎI PROXY (vd: voz.vn/proxy.php?image=...&hash=...) ==================
// Nhiều forum/site dùng 1 script proxy để chống hotlink, wrap URL ảnh gốc vào 1 query param.
// Reverse-image-search cần URL ảnh THẬT để Google có thể fetch được, nên phải bóc ra trước.
function unwrapProxyUrl(url) {
try {
const u = new URL(url, location.href);
const candidateKeys = ['image', 'img', 'url', 'src', 'target', 'u', 'imgurl', 'imageurl', 'dest'];
for (const key of candidateKeys) {
const val = u.searchParams.get(key); // URLSearchParams tự decode sẵn
if (val && /^https?:\/\//i.test(val)) {
return val;
}
}
} catch (err) {
console.warn('[ImageFormatSaver] unwrapProxyUrl lỗi:', err);
}
return url; // không nhận diện được dạng proxy -> giữ nguyên
}
// ================== TOAST NHỎ TỰ TẮT (không cần bấm OK) ==================
function showToast(message, duration) {
const toast = document.createElement('div');
toast.textContent = message;
Object.assign(toast.style, {
position: 'fixed',
bottom: '30px',
left: '50%',
transform: 'translateX(-50%)',
background: '#222',
color: '#fff',
padding: '10px 16px',
borderRadius: '6px',
fontSize: '13px',
lineHeight: '1.4',
fontFamily: 'system-ui, sans-serif',
zIndex: 2147483647,
boxShadow: '0 4px 14px rgba(0,0,0,.4)',
maxWidth: '90vw',
textAlign: 'center',
opacity: '0',
transition: 'opacity .25s ease',
pointerEvents: 'none',
});
document.body.appendChild(toast);
requestAnimationFrame(() => {
toast.style.opacity = '1';
});
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, duration || 3500);
}
// ================== TÌM KIẾM ẢNH TRÊN WEB (reverse image search) ==================
// Bóc URL thật ra khỏi proxy (nếu có, vd voz.vn/proxy.php?image=...), rồi kiểm tra
// Content-Type THẬT của ảnh qua GM_xmlhttpRequest (đáng tin cậy hơn đoán qua đuôi file,
// vì URL kiểu proxy/attachment thường không có đuôi rõ ràng). JPG -> tìm bằng Google.
// Các định dạng còn lại (PNG/WEBP/AVIF/GIF...) -> tìm bằng Yandex (đọc được WebP qua URL,
// Google thì không).
function getContentType(url) {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'HEAD',
url: url,
timeout: 4000,
onload: (res) => {
const headers = res.responseHeaders || '';
const match = headers.match(/content-type:\s*([^\r\n;]+)/i);
resolve(match ? match[1].trim().toLowerCase() : '');
},
onerror: () => resolve(''),
ontimeout: () => resolve(''),
});
});
}
async function searchImageOnWeb(img) {
const rawUrl = img && img.src ? img.src : '';
if (!rawUrl || rawUrl.startsWith('data:') || rawUrl.startsWith('blob:')) {
showToast('Không thể tìm kiếm ảnh này (không có URL công khai).');
return;
}
const url = unwrapProxyUrl(rawUrl);
// Đuôi file rõ ràng là jpg -> khỏi cần chờ request, dùng Google luôn cho nhanh
let isJpg = /\.jpe?g(\?|#|$)/i.test(url);
if (!isJpg) {
const contentType = await getContentType(url);
isJpg = contentType.indexOf('image/jpeg') !== -1 || contentType.indexOf('image/jpg') !== -1;
}
const searchUrl = isJpg
? 'https://www.google.com/searchbyimage?image_url=' + encodeURIComponent(url) + '&safe=off'
: 'https://yandex.com/images/search?rpt=imageview&url=' + encodeURIComponent(url);
REAL_WINDOW.open(searchUrl, '_blank', 'noopener');
}
async function searchCanvasOnWeb(canvas) {
let blob;
try {
blob = await canvasToPngBlob(canvas);
} catch (err) {
console.warn('[ImageFormatSaver] Không lấy được ảnh canvas để tìm kiếm:', err);
showToast('Không thể tìm kiếm ảnh này (canvas có thể bị taint do cross-origin).');
return;
}
try {
await copyBlobToClipboard(blob);
REAL_WINDOW.open('https://images.google.com/', '_blank', 'noopener');
showToast('Đã copy ảnh vào clipboard - dán Ctrl+V vào ô tìm kiếm Google Images.', 4500);
} catch (err) {
console.warn('[ImageFormatSaver] Copy ảnh canvas để search lỗi:', err);
showToast('Không copy được ảnh vào clipboard để tìm kiếm.');
}
}
// ================== MỞ HÌNH ẢNH TRONG TAB MỚI ==================
function openImageInNewTab(img) {
const url = img && img.src ? img.src : '';
if (!url || url.startsWith('data:') || url.startsWith('blob:')) {
alert('Ảnh này không có URL trực tiếp để mở tab mới.');
return;
}
REAL_WINDOW.open(url, '_blank', 'noopener');
}
// ================== CHỈNH SỬA ẢNH (editor tự viết, không phải tool gốc của Edge) ==================
function loadDrawableCanvas(target, isCanvasSource) {
return new Promise((resolve, reject) => {
const cloneFrom = (source) => {
const c = document.createElement('canvas');
c.width = source.naturalWidth || source.width;
c.height = source.naturalHeight || source.height;
const ctx = c.getContext('2d');
try {
ctx.drawImage(source, 0, 0, c.width, c.height);
} catch (err) {
reject(err);
return;
}
resolve(c);
};
if (isCanvasSource) {
cloneFrom(target);
return;
}
if (target.crossOrigin === 'anonymous' || target.src.startsWith(location.origin) || target.src.startsWith('data:')) {
cloneFrom(target);
return;
}
const testImg = new Image();
testImg.crossOrigin = 'anonymous';
testImg.onload = () => cloneFrom(testImg);
testImg.onerror = (err) => reject(err);
testImg.src = target.src;
});
}
async function openImageEditor(target, isCanvasSource) {
let workCanvas;
try {
workCanvas = await loadDrawableCanvas(target, isCanvasSource);
} catch (err) {
console.warn('[ImageFormatSaver] Không load được ảnh để sửa:', err);
alert('Không thể chỉnh sửa ảnh này (có thể bị chặn CORS hoặc canvas bị taint).');
return;
}
const history = [workCanvas.toDataURL('image/png')];
const overlay = document.createElement('div');
Object.assign(overlay.style, {
position: 'fixed', inset: '0', zIndex: 2147483647,
background: 'rgba(0,0,0,.6)', display: 'flex',
alignItems: 'center', justifyContent: 'center',
fontFamily: 'system-ui, sans-serif',
});
const panel = document.createElement('div');
Object.assign(panel.style, {
background: '#1e1e1e', color: '#eee', borderRadius: '8px',
boxShadow: '0 10px 40px rgba(0,0,0,.5)', display: 'flex',
flexDirection: 'column', maxWidth: '95vw', maxHeight: '92vh', overflow: 'hidden',
});
const header = document.createElement('div');
header.textContent = '✏️ Chỉnh sửa ảnh';
Object.assign(header.style, {
padding: '12px 18px', fontSize: '16px', fontWeight: '600', borderBottom: '1px solid #333',
});
const toolbar = document.createElement('div');
Object.assign(toolbar.style, {
display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center',
padding: '10px 14px', borderBottom: '1px solid #333', background: '#252525',
});
const canvasScrollWrap = document.createElement('div');
Object.assign(canvasScrollWrap.style, {
overflow: 'auto', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: '#111', flex: '1', padding: '14px', position: 'relative',
});
const displayCanvas = document.createElement('canvas');
Object.assign(displayCanvas.style, {
maxWidth: '100%', maxHeight: '70vh', background: '#fff', cursor: 'default',
boxShadow: '0 2px 10px rgba(0,0,0,.5)',
});
const footer = document.createElement('div');
Object.assign(footer.style, {
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
gap: '10px', padding: '12px 18px', borderTop: '1px solid #333', background: '#252525',
});
function btnStyle(el) {
Object.assign(el.style, {
padding: '6px 10px', borderRadius: '5px', border: '1px solid #444',
background: '#333', color: '#eee', cursor: 'pointer', fontSize: '13px',
});
el.addEventListener('mouseenter', () => (el.style.background = '#444'));
el.addEventListener('mouseleave', () => (el.style.background = '#333'));
}
function makeButton(label, onClick) {
const b = document.createElement('button');
b.textContent = label;
btnStyle(b);
b.addEventListener('click', onClick);
return b;
}
function redraw() {
displayCanvas.width = workCanvas.width;
displayCanvas.height = workCanvas.height;
const ctx = displayCanvas.getContext('2d');
ctx.clearRect(0, 0, displayCanvas.width, displayCanvas.height);
ctx.drawImage(workCanvas, 0, 0);
}
function pushHistory() {
history.push(workCanvas.toDataURL('image/png'));
if (history.length > 20) history.shift();
}
function restoreFromDataUrl(dataUrl, cb) {
const img = new Image();
img.onload = () => {
workCanvas.width = img.width;
workCanvas.height = img.height;
const ctx = workCanvas.getContext('2d');
ctx.clearRect(0, 0, workCanvas.width, workCanvas.height);
ctx.drawImage(img, 0, 0);
redraw();
if (cb) cb();
};
img.src = dataUrl;
}
function undo() {
if (history.length <= 1) return;
history.pop();
restoreFromDataUrl(history[history.length - 1]);
}
function reset() {
const first = history[0];
history.length = 1;
restoreFromDataUrl(first);
}
function rotate(deg) {
const rad = (deg * Math.PI) / 180;
const w = workCanvas.width;
const h = workCanvas.height;
const newCanvas = document.createElement('canvas');
newCanvas.width = h;
newCanvas.height = w;
const ctx = newCanvas.getContext('2d');
ctx.translate(newCanvas.width / 2, newCanvas.height / 2);
ctx.rotate(rad);
ctx.drawImage(workCanvas, -w / 2, -h / 2);
workCanvas.width = newCanvas.width;
workCanvas.height = newCanvas.height;
const wctx = workCanvas.getContext('2d');
wctx.clearRect(0, 0, workCanvas.width, workCanvas.height);
wctx.drawImage(newCanvas, 0, 0);
pushHistory();
redraw();
}
function flip(horizontal) {
const w = workCanvas.width;
const h = workCanvas.height;
const newCanvas = document.createElement('canvas');
newCanvas.width = w;
newCanvas.height = h;
const ctx = newCanvas.getContext('2d');
if (horizontal) {
ctx.translate(w, 0);
ctx.scale(-1, 1);
} else {
ctx.translate(0, h);
ctx.scale(1, -1);
}
ctx.drawImage(workCanvas, 0, 0);
const wctx = workCanvas.getContext('2d');
wctx.clearRect(0, 0, w, h);
wctx.drawImage(newCanvas, 0, 0);
pushHistory();
redraw();
}
let mode = null; // null | 'draw' | 'crop'
let brushColor = '#ff0000';
let brushSize = 4;
let drawing = false;
let lastX = 0, lastY = 0;
let cropStart = null;
let pendingCrop = null;
let cropRectEl = null;
function canvasCoordsFromEvent(e) {
const rect = displayCanvas.getBoundingClientRect();
const scaleX = displayCanvas.width / rect.width;
const scaleY = displayCanvas.height / rect.height;
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
}
function updateCropRectVisual(x1, y1, x2, y2) {
if (!cropRectEl) {
cropRectEl = document.createElement('div');
Object.assign(cropRectEl.style, {
position: 'absolute', border: '2px dashed #4ade80',
background: 'rgba(74,222,128,.15)', pointerEvents: 'none',
});
canvasScrollWrap.appendChild(cropRectEl);
}
const rect = displayCanvas.getBoundingClientRect();
const wrapRect = canvasScrollWrap.getBoundingClientRect();
const scaleX = rect.width / displayCanvas.width;
const scaleY = rect.height / displayCanvas.height;
const left = Math.min(x1, x2) * scaleX + (rect.left - wrapRect.left) + canvasScrollWrap.scrollLeft;
const top = Math.min(y1, y2) * scaleY + (rect.top - wrapRect.top) + canvasScrollWrap.scrollTop;
Object.assign(cropRectEl.style, {
left: left + 'px', top: top + 'px',
width: Math.abs(x2 - x1) * scaleX + 'px', height: Math.abs(y2 - y1) * scaleY + 'px',
display: 'block',
});
}
function removeCropRectVisual() {
if (cropRectEl) {
cropRectEl.remove();
cropRectEl = null;
}
}
function setMode(newMode) {
mode = newMode;
removeCropRectVisual();
pendingCrop = null;
displayCanvas.style.cursor = mode ? 'crosshair' : 'default';
}
displayCanvas.addEventListener('mousedown', (e) => {
if (mode === 'draw') {
drawing = true;
const { x, y } = canvasCoordsFromEvent(e);
lastX = x;
lastY = y;
} else if (mode === 'crop') {
cropStart = canvasCoordsFromEvent(e);
}
});
displayCanvas.addEventListener('mousemove', (e) => {
if (mode === 'draw' && drawing) {
const { x, y } = canvasCoordsFromEvent(e);
const ctx = workCanvas.getContext('2d');
ctx.strokeStyle = brushColor;
ctx.lineWidth = brushSize;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(x, y);
ctx.stroke();
lastX = x;
lastY = y;
redraw();
} else if (mode === 'crop' && cropStart) {
const { x, y } = canvasCoordsFromEvent(e);
updateCropRectVisual(cropStart.x, cropStart.y, x, y);
}
});
function endInteraction(e) {
if (mode === 'draw' && drawing) {
drawing = false;
pushHistory();
} else if (mode === 'crop' && cropStart) {
const { x, y } = canvasCoordsFromEvent(e);
const x1 = Math.max(0, Math.min(cropStart.x, x));
const y1 = Math.max(0, Math.min(cropStart.y, y));
const x2 = Math.min(workCanvas.width, Math.max(cropStart.x, x));
const y2 = Math.min(workCanvas.height, Math.max(cropStart.y, y));
cropStart = null;
const w = x2 - x1;
const h = y2 - y1;
if (w > 4 && h > 4) pendingCrop = { x: x1, y: y1, w, h };
}
}
displayCanvas.addEventListener('mouseup', endInteraction);
displayCanvas.addEventListener('mouseleave', () => {
if (drawing) {
drawing = false;
pushHistory();
}
});
function applyCrop() {
if (!pendingCrop) {
alert('Hãy kéo chọn vùng cần cắt trước khi bấm Áp dụng cắt.');
return;
}
const { x, y, w, h } = pendingCrop;
const newCanvas = document.createElement('canvas');
newCanvas.width = w;
newCanvas.height = h;
newCanvas.getContext('2d').drawImage(workCanvas, x, y, w, h, 0, 0, w, h);
workCanvas.width = w;
workCanvas.height = h;
const ctx = workCanvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
ctx.drawImage(newCanvas, 0, 0);
pendingCrop = null;
removeCropRectVisual();
pushHistory();
redraw();
}
toolbar.appendChild(makeButton('↺ Xoay trái', () => rotate(-90)));
toolbar.appendChild(makeButton('↻ Xoay phải', () => rotate(90)));
toolbar.appendChild(makeButton('⇋ Lật ngang', () => flip(true)));
toolbar.appendChild(makeButton('⇕ Lật dọc', () => flip(false)));
toolbar.appendChild(makeButton('▢ Cắt ảnh', () => setMode(mode === 'crop' ? null : 'crop')));
toolbar.appendChild(makeButton('✓ Áp dụng cắt', applyCrop));
toolbar.appendChild(makeButton('✎ Vẽ', () => setMode(mode === 'draw' ? null : 'draw')));
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.value = brushColor;
Object.assign(colorInput.style, { width: '30px', height: '28px', border: 'none', cursor: 'pointer' });
colorInput.addEventListener('input', () => (brushColor = colorInput.value));
toolbar.appendChild(colorInput);
const sizeInput = document.createElement('input');
sizeInput.type = 'range';
sizeInput.min = '1';
sizeInput.max = '30';
sizeInput.value = String(brushSize);
Object.assign(sizeInput.style, { width: '80px' });
sizeInput.addEventListener('input', () => (brushSize = parseInt(sizeInput.value, 10)));
toolbar.appendChild(sizeInput);
toolbar.appendChild(makeButton('↶ Hoàn tác', undo));
toolbar.appendChild(makeButton('⟲ Đặt lại', reset));
const formatSelect = document.createElement('select');
['png', 'jpg', 'webp'].forEach((ext) => {
const opt = document.createElement('option');
opt.value = ext;
opt.textContent = ext.toUpperCase();
formatSelect.appendChild(opt);
});
Object.assign(formatSelect.style, {
padding: '6px 8px', borderRadius: '5px', border: '1px solid #444',
background: '#333', color: '#eee', fontSize: '13px',
});
const saveBtn = document.createElement('button');
saveBtn.textContent = '💾 Lưu ảnh đã sửa';
Object.assign(saveBtn.style, {
padding: '8px 16px', borderRadius: '5px', border: 'none',
background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: '600',
});
saveBtn.addEventListener('click', async () => {
const ext = formatSelect.value;
const mime = ext === 'png' ? 'image/png' : ext === 'jpg' ? 'image/jpeg' : 'image/webp';
const filename = 'edited.' + ext;
const handle = await pickSaveHandle(filename, mime);
if (handle === 'cancelled') return;
workCanvas.toBlob(
(blob) => {
if (!blob) {
alert('Không tạo được ảnh để lưu.');
return;
}
writeBlob(blob, filename, handle);
},
mime,
ext === 'png' ? undefined : getQuality(ext)
);
});
const closeBtn = makeButton('Đóng', () => overlay.remove());
const leftFooter = document.createElement('div');
Object.assign(leftFooter.style, { display: 'flex', gap: '8px', alignItems: 'center' });
leftFooter.appendChild(formatSelect);
leftFooter.appendChild(saveBtn);
footer.appendChild(leftFooter);
footer.appendChild(closeBtn);
canvasScrollWrap.appendChild(displayCanvas);
panel.appendChild(header);
panel.appendChild(toolbar);
panel.appendChild(canvasScrollWrap);
panel.appendChild(footer);
overlay.appendChild(panel);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
document.addEventListener('keydown', function escClose(e) {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', escClose);
}
});
document.body.appendChild(overlay);
redraw();
}
// ================== MENU CUSTOM ==================
function buildMenuGeneric(x, y, onPick, topGroups, bottomItems) {
removeMenu();
menuEl = document.createElement('div');
Object.assign(menuEl.style, {
position: 'fixed',
top: y + 'px',
left: x + 'px',
zIndex: 2147483647,
background: '#2b2b2b',
color: '#fff',
font: '13px/1.4 system-ui, sans-serif',
borderRadius: '6px',
boxShadow: '0 4px 14px rgba(0,0,0,.4)',
padding: '4px 0',
minWidth: '160px',
userSelect: 'none',
});
function addItem(label, onClick) {
const item = document.createElement('div');
item.textContent = label;
Object.assign(item.style, { padding: '6px 14px', cursor: 'pointer' });
item.addEventListener('mouseenter', () => (item.style.background = '#444'));
item.addEventListener('mouseleave', () => (item.style.background = 'transparent'));
item.addEventListener('click', async (e) => {
e.stopPropagation();
removeMenu();
await onClick();
});
menuEl.appendChild(item);
}
function addDivider() {
const divider = document.createElement('div');
Object.assign(divider.style, { borderTop: '1px solid #555', margin: '4px 0' });
menuEl.appendChild(divider);
}
// topGroups: mảng các nhóm, mỗi nhóm là 1 mảng item -> mỗi nhóm có đường kẻ riêng phía dưới
(topGroups || []).forEach((group) => {
(group || []).forEach((it) => addItem(it.label, it.onClick));
if (group && group.length) addDivider();
});
FORMATS.forEach((fmt) => {
addItem(fmt.label, () => onPick(fmt));
});
if (bottomItems && bottomItems.length) {
addDivider();
bottomItems.forEach((it) => addItem(it.label, it.onClick));
}
document.body.appendChild(menuEl);
const rect = menuEl.getBoundingClientRect();
if (rect.right > window.innerWidth) menuEl.style.left = window.innerWidth - rect.width - 8 + 'px';
if (rect.bottom > window.innerHeight) menuEl.style.top = window.innerHeight - rect.height - 8 + 'px';
setTimeout(() => {
document.addEventListener('click', removeMenu, true);
document.addEventListener('scroll', removeMenu, true);
document.addEventListener('keydown', onKeyDown, true);
}, 0);
}
function buildMenu(x, y, img) {
const topGroups = [
[{ label: 'Mở hình ảnh trong tab mới', onClick: () => openImageInNewTab(img) }],
[
{ label: 'Copy ảnh', onClick: () => copyImageToClipboard(img) },
{ label: 'Copy link ảnh', onClick: () => copyImageLink(img) },
],
];
const bottomItems = [
{ label: 'Chỉnh sửa ảnh', onClick: () => openImageEditor(img, false) },
{ label: 'Tìm kiếm ảnh', onClick: () => searchImageOnWeb(img) },
];
buildMenuGeneric(
x,
y,
async (fmt) => {
const filename = suggestedFileName(img, fmt.ext);
const handle = await pickSaveHandle(filename, fmt.mime);
if (handle === 'cancelled') return;
saveImageAs(img, fmt, handle);
},
topGroups,
bottomItems
);
}
function buildMenuForCanvas(x, y, canvas) {
// Canvas (vd: WebGL flipbook) không có URL ảnh gốc -> không có "Mở tab mới" / "Copy link ảnh".
// Nhưng vẫn tìm kiếm được nhờ copy pixel trực tiếp vào clipboard.
const topGroups = [[{ label: 'Copy ảnh', onClick: () => copyCanvasToClipboard(canvas) }]];
const bottomItems = [
{ label: 'Chỉnh sửa ảnh', onClick: () => openImageEditor(canvas, true) },
{ label: 'Tìm kiếm ảnh', onClick: () => searchCanvasOnWeb(canvas) },
];
buildMenuGeneric(
x,
y,
async (fmt) => {
const filename = 'image.' + fmt.ext;
const handle = await pickSaveHandle(filename, fmt.mime);
if (handle === 'cancelled') return;
saveImageAsCanvas(canvas, fmt, handle);
},
topGroups,
bottomItems
);
}
// ================== PHÁT HIỆN TARGET LÀ ẢNH ==================
function findImageLikeTarget(el) {
if (!el) return null;
if (el.tagName === 'IMG' && el.src) return { type: 'img', el };
if (el.tagName === 'CANVAS') return { type: 'canvas', el };
if (el.tagName === 'image' || el.tagName === 'IMAGE') return { type: 'svg', el };
return null;
}
window.addEventListener(
'contextmenu',
(e) => {
let found = findImageLikeTarget(e.target);
if (!found && document.elementsFromPoint) {
const stack = document.elementsFromPoint(e.clientX, e.clientY);
for (const el of stack) {
found = findImageLikeTarget(el);
if (found) break;
}
}
if (!found) return; // không phải ảnh -> KHÔNG động vào gì cả, để Edge hiện menu gốc bình thường
// Là ảnh -> chặn tuyệt đối menu gốc của Edge, kể cả khi có handler khác của trang
// (hoặc của Edge) cũng đang lắng nghe sự kiện này.
e.preventDefault();
e.stopImmediatePropagation();
if (found.type === 'canvas') {
buildMenuForCanvas(e.clientX, e.clientY, found.el);
} else if (found.type === 'img') {
buildMenu(e.clientX, e.clientY, found.el);
} else if (found.type === 'svg') {
const href = found.el.getAttribute('href') || found.el.getAttribute('xlink:href');
if (href) {
const fakeImg = new Image();
fakeImg.src = href;
buildMenu(e.clientX, e.clientY, fakeImg);
}
}
},
true
);
})();