151de492ad
승인 대기 페이지의 "첨부" 텍스트 링크를 클립 아이콘 버튼으로 교체.
클릭 시 모달 팝업으로 이미지 미리보기 + 마우스 펜 주석 + 다운로드.
뷰어 컴포넌트 (전역, 다른 페이지에서도 재사용 가능):
- 좌측: 첨부 목록 (썸네일/메타)
- 우측: 캔버스 스택 (img + 투명 canvas)
- 도구: 펜/지우개, 색상 4종, 굵기 3종, 전체 지우기, 합성 PNG 저장, 원본 열기
- 이미지 외 파일은 다운로드 링크만 표시
- ESC/배경 클릭으로 닫기, pointer events 지원
전역 노출: window.ErpAttachViewer.{open, openFor, close}
321 lines
10 KiB
JavaScript
321 lines
10 KiB
JavaScript
// ──────────────────────────────────────────────────────────
|
|
// 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 = `
|
|
<div class="eav-modal" role="dialog" aria-modal="true">
|
|
<div class="eav-head">
|
|
<div class="eav-title" data-role="title">첨부 보기</div>
|
|
<div class="eav-toolbar" data-role="toolbar"></div>
|
|
<button class="eav-close" data-role="close" aria-label="닫기">✕</button>
|
|
</div>
|
|
<div class="eav-body">
|
|
<div class="eav-list" data-role="list"></div>
|
|
<div class="eav-canvas-wrap" data-role="stage"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<span class="eav-item-thumb">${
|
|
isImg
|
|
? `<img src="/expense/api/attachments/${a.id}" alt="" />`
|
|
: "📄"
|
|
}</span>
|
|
<span class="eav-item-meta">
|
|
<span class="eav-item-name">${escapeHtml(a.filename)}</span>
|
|
<span class="eav-item-sub">${a.kind === "receipt" ? "영수증" : "기타"} · ${fmtSize(a.size_bytes)}</span>
|
|
</span>
|
|
`;
|
|
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 = `<div class="eav-fallback">첨부가 없습니다.</div>`;
|
|
return;
|
|
}
|
|
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
|
|
if (!IMG_EXTS.has(ext)) {
|
|
stage.innerHTML = `
|
|
<div class="eav-fallback">
|
|
이미지 파일이 아닙니다.<br/>
|
|
<a href="/expense/api/attachments/${cur.id}" target="_blank">다운로드 / 새 창 열기</a>
|
|
</div>
|
|
`;
|
|
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 = `<div class="eav-fallback">이미지 로드 실패</div>`;
|
|
};
|
|
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 };
|
|
})();
|