175 lines
6.0 KiB
JavaScript
Executable File
175 lines
6.0 KiB
JavaScript
Executable File
/**
|
||
* imageCompress.js
|
||
* 업로드 전 브라우저에서 이미지를 리사이즈 + JPEG 압축
|
||
* Canvas API 사용 — 외부 라이브러리 불필요
|
||
*/
|
||
const ImageCompressor = (() => {
|
||
|
||
// 서버에서 가져온 설정 캐시
|
||
let _cfg = null;
|
||
|
||
/** 관리자가 저장한 이미지 설정 로드 (최초 1회만 API 호출) */
|
||
async function loadConfig() {
|
||
if (_cfg) return _cfg;
|
||
try {
|
||
const res = await fetch('/api/settings/public');
|
||
_cfg = await res.json();
|
||
} catch {
|
||
_cfg = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 };
|
||
}
|
||
return _cfg;
|
||
}
|
||
|
||
/**
|
||
* File 객체 하나를 압축해서 새 File 로 반환
|
||
* @param {File} file - 원본 이미지 파일
|
||
* @param {Object} cfg - { image_compress_enabled, image_max_px, image_quality }
|
||
* @returns {Promise<File>}
|
||
*/
|
||
function compressOne(file, cfg) {
|
||
return new Promise((resolve) => {
|
||
// 압축 비활성 or 이미지가 아닌 파일은 그대로 반환
|
||
if (!cfg.image_compress_enabled || !file.type.startsWith('image/')) {
|
||
return resolve(file);
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const maxPx = cfg.image_max_px;
|
||
let { width, height } = img;
|
||
|
||
// 긴 변이 maxPx 초과하면 비율 유지하며 축소
|
||
if (width > maxPx || height > maxPx) {
|
||
if (width >= height) {
|
||
height = Math.round((height / width) * maxPx);
|
||
width = maxPx;
|
||
} else {
|
||
width = Math.round((width / height) * maxPx);
|
||
height = maxPx;
|
||
}
|
||
}
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
||
|
||
canvas.toBlob(
|
||
(blob) => {
|
||
const compressed = new File(
|
||
[blob],
|
||
file.name.replace(/\.[^.]+$/, '') + '.jpg',
|
||
{ type: 'image/jpeg', lastModified: Date.now() }
|
||
);
|
||
resolve(compressed);
|
||
},
|
||
'image/jpeg',
|
||
cfg.image_quality / 100 // 0~1 범위
|
||
);
|
||
};
|
||
img.src = e.target.result;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* FileList / File[] 전체를 압축
|
||
* @param {FileList|File[]} files
|
||
* @returns {Promise<File[]>}
|
||
*/
|
||
async function compressAll(files) {
|
||
const cfg = await loadConfig();
|
||
return Promise.all(Array.from(files).map(f => compressOne(f, cfg)));
|
||
}
|
||
|
||
/**
|
||
* input[type=file] + 미리보기 div 에 압축 프리뷰 설정
|
||
* @param {string} inputId - file input 요소 id
|
||
* @param {string} previewId - 미리보기 컨테이너 id
|
||
* @param {string} infoId - 용량 정보 표시 span id (선택)
|
||
*/
|
||
function setupPreview(inputId, previewId, infoId) {
|
||
const input = document.getElementById(inputId);
|
||
const preview = document.getElementById(previewId);
|
||
const info = infoId ? document.getElementById(infoId) : null;
|
||
|
||
if (!input || !preview) return;
|
||
|
||
input.addEventListener('change', async function () {
|
||
preview.innerHTML = '';
|
||
if (info) info.textContent = '압축 중...';
|
||
|
||
const cfg = await loadConfig();
|
||
const origBytes = Array.from(this.files).reduce((s, f) => s + f.size, 0);
|
||
|
||
const compressed = await compressAll(this.files);
|
||
const compBytes = compressed.reduce((s, f) => s + f.size, 0);
|
||
|
||
// DataTransfer 로 input.files 교체 (압축된 파일로)
|
||
const dt = new DataTransfer();
|
||
compressed.forEach(f => dt.items.add(f));
|
||
this.files = dt.files;
|
||
|
||
// 미리보기 렌더링
|
||
compressed.forEach((f, i) => {
|
||
const url = URL.createObjectURL(f);
|
||
const wrap = document.createElement('div');
|
||
wrap.style.cssText = 'position:relative;display:inline-block;';
|
||
|
||
const img = document.createElement('img');
|
||
img.src = url;
|
||
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid #C5CFE0;';
|
||
img.onload = () => URL.revokeObjectURL(url);
|
||
|
||
// 삭제 버튼
|
||
const del = document.createElement('button');
|
||
del.textContent = '×';
|
||
del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;background:#E53935;color:white;border:none;font-size:11px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;padding:0;';
|
||
del.onclick = () => {
|
||
// 해당 파일 제거
|
||
const cur = Array.from(input.files);
|
||
cur.splice(i, 1);
|
||
const dt2 = new DataTransfer();
|
||
cur.forEach(f2 => dt2.items.add(f2));
|
||
input.files = dt2.files;
|
||
wrap.remove();
|
||
updateInfo(input, info);
|
||
};
|
||
|
||
wrap.appendChild(img);
|
||
wrap.appendChild(del);
|
||
preview.appendChild(wrap);
|
||
});
|
||
|
||
// 용량 정보
|
||
if (info) {
|
||
const pct = origBytes > 0 ? Math.round((1 - compBytes / origBytes) * 100) : 0;
|
||
if (cfg.image_compress_enabled && pct > 0) {
|
||
info.textContent = `${compressed.length}장 | ${fmt(origBytes)} → ${fmt(compBytes)} (${pct}% 절약) | 최대 ${cfg.image_max_px}px / 품질 ${cfg.image_quality}%`;
|
||
info.style.color = '#00875A';
|
||
} else {
|
||
info.textContent = `${compressed.length}장 | ${fmt(compBytes)} (압축 비활성)`;
|
||
info.style.color = '#8899BB';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateInfo(input, info) {
|
||
if (!info) return;
|
||
const bytes = Array.from(input.files).reduce((s, f) => s + f.size, 0);
|
||
info.textContent = `${input.files.length}장 | ${fmt(bytes)}`;
|
||
}
|
||
|
||
function fmt(bytes) {
|
||
return bytes < 1024 * 1024
|
||
? (bytes / 1024).toFixed(0) + ' KB'
|
||
: (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
}
|
||
|
||
return { compressAll, setupPreview, loadConfig };
|
||
})();
|