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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user