// 프로필 사진 업로드 — 파일 선택 → 정사각 크롭(드래그 이동 + 확대) → 256px webp 로 서버 전송. // 서버 이미지 라이브러리 없이 브라우저 canvas 에서 자르고 축소한다. import { $, toast } from "./util.js"; const OUT = 256; // 저장 이미지 한 변(px) export function initProfile() { const editBtn = $("#avatarEditBtn"); const modal = $("#cropModal"); if (!editBtn || !modal) return; // 본인 프로필이 아니면 크롭 UI 없음 const stage = $("#cropStage"); const img = $("#cropImg"); const zoom = $("#cropZoom"); const fileInput = $("#avatarFile"); const saveBtn = $("#cropSave"); const pickBtn = $("#cropPick"); const closeBtn = $("#cropClose"); const avatarEls = document.querySelectorAll('[data-avatar], #profileAvatar'); // 크롭 상태: 스테이지(정사각)를 뷰포트로 보고, 그 안에 이미지가 항상 뷰포트를 덮도록 배치. const st = { nw: 0, nh: 0, base: 1, zoom: 1, tx: 0, ty: 0, S: 0 }; function stageSize() { return stage.clientWidth; } // 정사각(= clientHeight) function scale() { return st.base * st.zoom; } // 이미지가 뷰포트를 항상 덮도록 tx,ty 를 제한. function clamp() { const dispW = st.nw * scale(); const dispH = st.nh * scale(); st.tx = Math.min(0, Math.max(st.S - dispW, st.tx)); st.ty = Math.min(0, Math.max(st.S - dispH, st.ty)); } function render() { const dispW = st.nw * scale(); const dispH = st.nh * scale(); img.style.width = dispW + "px"; img.style.height = dispH + "px"; img.style.transform = `translate(${st.tx}px, ${st.ty}px)`; } function loadImage(src) { img.onload = () => { st.nw = img.naturalWidth; st.nh = img.naturalHeight; st.S = stageSize(); // cover: 짧은 변이 스테이지를 채우도록. st.base = st.S / Math.min(st.nw, st.nh); st.zoom = 1; if (zoom) zoom.value = "1"; // 가운데 정렬 st.tx = (st.S - st.nw * scale()) / 2; st.ty = (st.S - st.nh * scale()) / 2; clamp(); render(); }; img.src = src; } function openModal() { modal.hidden = false; document.body.style.overflow = "hidden"; } function closeModal() { modal.hidden = true; document.body.style.overflow = ""; } editBtn.addEventListener("click", () => fileInput.click()); if (pickBtn) pickBtn.addEventListener("click", () => fileInput.click()); if (closeBtn) closeBtn.addEventListener("click", closeModal); modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); }); fileInput.addEventListener("change", () => { const f = fileInput.files && fileInput.files[0]; if (!f) return; if (!/^image\/(png|jpeg|webp)$/.test(f.type)) { toast("PNG·JPG·WEBP 이미지만 올릴 수 있어요"); return; } const reader = new FileReader(); reader.onload = () => { loadImage(reader.result); openModal(); }; reader.readAsDataURL(f); }); // 확대 슬라이더 — 스테이지 중심을 고정한 채 확대/축소. if (zoom) zoom.addEventListener("input", () => { const cx = st.S / 2, cy = st.S / 2; const prev = scale(); const ix = (cx - st.tx) / prev; // 중심에 대응하는 이미지 좌표 const iy = (cy - st.ty) / prev; st.zoom = parseFloat(zoom.value) || 1; st.tx = cx - ix * scale(); st.ty = cy - iy * scale(); clamp(); render(); }); // 드래그 이동 (포인터 이벤트) let dragging = false, px = 0, py = 0; stage.addEventListener("pointerdown", (e) => { dragging = true; px = e.clientX; py = e.clientY; stage.setPointerCapture(e.pointerId); }); stage.addEventListener("pointermove", (e) => { if (!dragging) return; st.tx += e.clientX - px; st.ty += e.clientY - py; px = e.clientX; py = e.clientY; clamp(); render(); }); const endDrag = () => { dragging = false; }; stage.addEventListener("pointerup", endDrag); stage.addEventListener("pointercancel", endDrag); saveBtn.addEventListener("click", async () => { if (!st.nw) return; const sc = scale(); // 스테이지 뷰포트(0..S) 를 이미지 원본 좌표로 역변환. const sx = (0 - st.tx) / sc; const sy = (0 - st.ty) / sc; const sSize = st.S / sc; const canvas = document.createElement("canvas"); canvas.width = OUT; canvas.height = OUT; const ctx = canvas.getContext("2d"); ctx.imageSmoothingQuality = "high"; ctx.drawImage(img, sx, sy, sSize, sSize, 0, 0, OUT, OUT); const blob = await new Promise((r) => canvas.toBlob(r, "image/webp", 0.85)); if (!blob) { toast("이미지 변환에 실패했어요"); return; } saveBtn.disabled = true; saveBtn.textContent = "저장 중…"; try { const res = await fetch("/profile/avatar", { method: "POST", headers: { "Content-Type": "image/webp", "X-Requested-With": "fetch" }, body: blob, }); if (!res.ok) throw new Error("upload failed"); // 캐시 무력화를 위해 모든 내 아바타 src 에 타임스탬프 부여. const bust = "?t=" + Date.now(); avatarEls.forEach((el) => { const base = (el.getAttribute("src") || "").split("?")[0]; if (base) el.setAttribute("src", base + bust); }); toast("프로필 사진이 변경됐어요"); closeModal(); } catch (e) { toast("업로드에 실패했어요"); } finally { saveBtn.disabled = false; saveBtn.textContent = "저장"; } }); } initProfile();