/** * 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} */ 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} */ 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 }; })();