feat(B01): 서명 그리기 캔버스 추가

- 자산 모달 서명 칸에 마우스 그리기 판 신설 — pointer 이벤트 + setPointerCapture,
  getBoundingClientRect 로 CSS 크기 → 캔버스 픽셀 환산, [지우기] 1개
- 올릴 때 canvas.toBlob('image/png') → signature.png 로 기존 업로드 통로
  (POST /company/assets) 재사용 — 백엔드·DB 변경 없음
- 파일을 안 고르면 그린 서명을 대신 실어 보냄, 로고 칸에는 캔버스 없음
- 검증(5174 공용 브라우저): 좌표 환산 실측 x 98–381 · y 38–111(요청 100–380 · 40–110),
  [지우기] 후 잉크 0, 저장 PNG 480×160 RGBA 5,250 bytes,
  프로젝트 signature_asset_id 2→3 반영 확인 후 원복. pytest 143 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 18:04:44 +09:00
co-authored by Claude Opus 5
parent 1a9947426a
commit 5fc8009925
2 changed files with 98 additions and 3 deletions
+86 -3
View File
@@ -69,6 +69,82 @@ export function createAssetField(
return { root, value: () => selected?.id ?? null };
}
/**
* 서명 그리기 칸 — 마우스·펜으로 획을 긋고 PNG(투명 배경)로 넘긴다.
* 파일 업로드와 같은 통로(`POST /company/assets`)를 쓰므로 백엔드는 손대지 않는다.
*/
function createSignaturePad(): { root: HTMLDivElement; toFile: () => Promise<File | null> } {
const root = document.createElement("div");
root.className = "ui-field";
const caption = document.createElement("label");
caption.className = "ui-field__label";
caption.textContent = "또는 마우스로 그리기";
const canvas = document.createElement("canvas");
canvas.width = 480;
canvas.height = 160;
canvas.className = "b01-dashboard__sign-pad";
const context = canvas.getContext("2d");
let drawn = false;
let drawing = false;
if (context) {
context.lineWidth = 2.5;
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = "#111111";
}
// 캔버스 좌표는 CSS 크기가 아니라 픽셀 크기 기준이라 비율로 환산한다.
const at = (event: PointerEvent): [number, number] => {
const box = canvas.getBoundingClientRect();
return [
((event.clientX - box.left) * canvas.width) / box.width,
((event.clientY - box.top) * canvas.height) / box.height,
];
};
canvas.addEventListener("pointerdown", (event) => {
if (!context) return;
drawing = true;
drawn = true;
canvas.setPointerCapture(event.pointerId);
const [x, y] = at(event);
context.beginPath();
context.moveTo(x, y);
});
canvas.addEventListener("pointermove", (event) => {
if (!drawing || !context) return;
const [x, y] = at(event);
context.lineTo(x, y);
context.stroke();
});
const stop = (): void => {
drawing = false;
};
canvas.addEventListener("pointerup", stop);
canvas.addEventListener("pointercancel", stop);
const clear = createButton({
label: "지우기",
variant: "ghost",
onClick: () => {
context?.clearRect(0, 0, canvas.width, canvas.height);
drawn = false;
},
});
root.append(caption, canvas, clear);
return {
root,
toFile: () =>
new Promise((resolve) => {
if (!drawn) return resolve(null);
canvas.toBlob(
(blob) => resolve(blob ? new File([blob], "signature.png", { type: "image/png" }) : null),
"image/png",
);
}),
};
}
function openAssetPickerModal(
kind: CompanyAsset["kind"],
assets: CompanyAsset[],
@@ -152,12 +228,17 @@ function openAssetPickerModal(
mineBox.type = "checkbox";
mineBox.checked = kind === "SIGNATURE";
mine.append(mineBox, document.createTextNode(` 내 계정(${user.name})에 물리기`));
const pad = kind === "SIGNATURE" ? createSignaturePad() : null;
const add = createButton({
label: "올리고 선택",
onClick: async () => {
const chosen = file.input.files?.[0];
if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요.");
if (!chosen) return file.setError("그림 파일을 고르세요.");
const chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null;
if (!chosen) {
return file.setError(
pad ? "그림 파일을 고르거나 서명을 그리세요." : "그림 파일을 고르세요.",
);
}
const form = new FormData();
form.append("kind", kind);
form.append("label", label.input.value.trim());
@@ -178,7 +259,9 @@ function openAssetPickerModal(
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
actions.append(createButton({ label: "닫기", variant: "ghost", onClick: close }));
panel.append(heading, grid, addTitle, label.root, file.root, mine, add, actions);
panel.append(heading, grid, addTitle, label.root, file.root);
if (pad) panel.append(pad.root);
panel.append(mine, add, actions);
modal.append(panel);
document.body.append(modal);
}
+12
View File
@@ -241,6 +241,18 @@
object-fit: contain;
}
/* 서명 그리기 판 — touch-action 을 끊어야 끌기가 스크롤로 새지 않는다 */
.b01-dashboard__sign-pad {
width: 100%;
height: auto;
aspect-ratio: 3 / 1;
border: 1px dashed var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
cursor: crosshair;
touch-action: none;
}
.b01-dashboard__check {
display: block;
margin: var(--spacing-8) 0 var(--spacing-16);