// ────────────────────────────────────────────────────────── // ErpAttachViewer — 첨부 모달 // 사용: // ErpAttachViewer.openFor(itemId, { title }) // 동작: // - GET /expense/api/items/{itemId}/attachments // - 이미지: 캔버스 위 펜 주석, 다운로드(원본+주석 합성) // - 비이미지: 새 창 다운로드 링크 // ────────────────────────────────────────────────────────── (function () { if (window.ErpAttachViewer) return; const IMG_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]); const COLORS = ["#c22b10", "#0a0a0a", "#1d4ed8", "#10c22b"]; const SIZES = [2, 4, 8]; let overlay = null; let state = { items: [], currentIdx: -1, tool: "pen", // pen | erase color: COLORS[0], size: SIZES[1], drawing: false, last: null, title: "", }; function ensureOverlay() { if (overlay) return overlay; overlay = document.createElement("div"); overlay.className = "eav-overlay"; overlay.innerHTML = ` `; document.body.appendChild(overlay); overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); overlay.querySelector('[data-role="close"]').addEventListener("click", close); document.addEventListener("keydown", (e) => { if (overlay.dataset.open === "true" && e.key === "Escape") close(); }); return overlay; } function buildToolbar() { const tb = overlay.querySelector('[data-role="toolbar"]'); tb.innerHTML = ""; const tools = [ { key: "pen", label: "✏️ 펜" }, { key: "erase", label: "🩹 지우개" }, ]; tools.forEach((t) => { const b = document.createElement("button"); b.className = "eav-btn" + (state.tool === t.key ? " is-active" : ""); b.textContent = t.label; b.onclick = () => { state.tool = t.key; buildToolbar(); }; tb.appendChild(b); }); COLORS.forEach((c) => { const b = document.createElement("button"); b.className = "eav-color" + (state.color === c ? " is-active" : ""); b.style.background = c; b.title = c; b.onclick = () => { state.color = c; buildToolbar(); }; tb.appendChild(b); }); SIZES.forEach((s) => { const b = document.createElement("button"); b.className = "eav-btn" + (state.size === s ? " is-active" : ""); b.textContent = `${s}px`; b.onclick = () => { state.size = s; buildToolbar(); }; tb.appendChild(b); }); const clear = document.createElement("button"); clear.className = "eav-btn"; clear.textContent = "전체 지우기"; clear.onclick = clearAnnot; tb.appendChild(clear); const dl = document.createElement("button"); dl.className = "eav-btn"; dl.textContent = "💾 저장"; dl.onclick = downloadComposite; tb.appendChild(dl); const original = document.createElement("a"); original.className = "eav-btn"; original.textContent = "원본"; const cur = state.items[state.currentIdx]; if (cur) { original.href = `/expense/api/attachments/${cur.id}`; original.target = "_blank"; } tb.appendChild(original); } function buildList() { const list = overlay.querySelector('[data-role="list"]'); list.innerHTML = ""; state.items.forEach((a, i) => { const item = document.createElement("button"); item.className = "eav-item" + (i === state.currentIdx ? " is-active" : ""); const ext = (a.filename.split(".").pop() || "").toLowerCase(); const isImg = IMG_EXTS.has(ext); item.innerHTML = ` ${ isImg ? `` : "📄" } ${escapeHtml(a.filename)} ${a.kind === "receipt" ? "영수증" : "기타"} · ${fmtSize(a.size_bytes)} `; item.onclick = () => { state.currentIdx = i; renderCurrent(); buildList(); buildToolbar(); }; list.appendChild(item); }); } function renderCurrent() { const stage = overlay.querySelector('[data-role="stage"]'); stage.innerHTML = ""; const cur = state.items[state.currentIdx]; if (!cur) { stage.innerHTML = `
첨부가 없습니다.
`; return; } const ext = (cur.filename.split(".").pop() || "").toLowerCase(); if (!IMG_EXTS.has(ext)) { stage.innerHTML = `
이미지 파일이 아닙니다.
다운로드 / 새 창 열기
`; return; } const wrap = document.createElement("div"); wrap.className = "eav-canvas-stack"; const img = new Image(); img.className = "eav-img"; img.crossOrigin = "anonymous"; img.onload = () => { const canvas = document.createElement("canvas"); canvas.className = "eav-canvas"; canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; // 표시 크기는 img 의 렌더링 크기 따라감 canvas.style.width = img.clientWidth + "px"; canvas.style.height = img.clientHeight + "px"; wrap.appendChild(canvas); attachDrawing(canvas, img); // 창 리사이즈 시 캔버스 표시 크기 재조정 const ro = new ResizeObserver(() => { canvas.style.width = img.clientWidth + "px"; canvas.style.height = img.clientHeight + "px"; }); ro.observe(img); state.canvas = canvas; state.image = img; }; img.onerror = () => { stage.innerHTML = `
이미지 로드 실패
`; }; img.src = `/expense/api/attachments/${cur.id}`; wrap.appendChild(img); stage.appendChild(wrap); } function attachDrawing(canvas, img) { const ctx = canvas.getContext("2d"); function posToCanvas(ev) { const rect = canvas.getBoundingClientRect(); const x = (ev.clientX - rect.left) * (canvas.width / rect.width); const y = (ev.clientY - rect.top) * (canvas.height / rect.height); return { x, y }; } function start(ev) { ev.preventDefault(); state.drawing = true; state.last = posToCanvas(ev); } function move(ev) { if (!state.drawing) return; ev.preventDefault(); const p = posToCanvas(ev); ctx.lineCap = "round"; ctx.lineJoin = "round"; if (state.tool === "erase") { ctx.globalCompositeOperation = "destination-out"; ctx.lineWidth = state.size * 4; } else { ctx.globalCompositeOperation = "source-over"; ctx.strokeStyle = state.color; ctx.lineWidth = state.size; } ctx.beginPath(); ctx.moveTo(state.last.x, state.last.y); ctx.lineTo(p.x, p.y); ctx.stroke(); state.last = p; } function end(ev) { if (ev) ev.preventDefault(); state.drawing = false; state.last = null; } canvas.addEventListener("pointerdown", start); canvas.addEventListener("pointermove", move); canvas.addEventListener("pointerup", end); canvas.addEventListener("pointerleave", end); } function clearAnnot() { if (!state.canvas) return; const ctx = state.canvas.getContext("2d"); ctx.clearRect(0, 0, state.canvas.width, state.canvas.height); } async function downloadComposite() { if (!state.canvas || !state.image) { alert("이미지가 로드되지 않았습니다."); return; } const cur = state.items[state.currentIdx]; const out = document.createElement("canvas"); out.width = state.canvas.width; out.height = state.canvas.height; const octx = out.getContext("2d"); try { octx.drawImage(state.image, 0, 0); } catch (e) { alert("이미지 합성 실패 (CORS): 원본만 다운로드하세요."); return; } octx.drawImage(state.canvas, 0, 0); out.toBlob((blob) => { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; const base = cur.filename.replace(/\.[^.]+$/, ""); a.download = `${base}_주석.png`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }, "image/png"); } function fmtSize(b) { b = Number(b || 0); if (b < 1024) return `${b} B`; if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`; return `${(b / 1024 / 1024).toFixed(2)} MB`; } function escapeHtml(s) { return String(s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) ); } function open(items, opts) { ensureOverlay(); state.items = items || []; state.currentIdx = state.items.length ? 0 : -1; state.title = (opts && opts.title) || "첨부 보기"; overlay.querySelector('[data-role="title"]').textContent = state.title; overlay.dataset.open = "true"; buildList(); buildToolbar(); renderCurrent(); } function close() { if (!overlay) return; overlay.dataset.open = "false"; state.items = []; state.currentIdx = -1; state.canvas = null; state.image = null; } async function openFor(itemId, opts) { try { const res = await fetch(`/expense/api/items/${itemId}/attachments`); if (!res.ok) throw new Error((await res.json()).detail || res.status); const { attachments } = await res.json(); if (!attachments || !attachments.length) { alert("첨부 파일이 없습니다."); return; } open(attachments, opts); } catch (err) { alert(`첨부 로드 실패: ${err.message || err}`); } } window.ErpAttachViewer = { open, openFor, close }; })();