From 5fc800992543e3099bded27f3fcd1cb67e84ad04 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 18:04:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(B01):=20=EC=84=9C=EB=AA=85=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0=20=EC=BA=94=EB=B2=84=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자산 모달 서명 칸에 마우스 그리기 판 신설 — 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) --- B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts | 89 ++++++++++++++++++- B01_Dashboard/B01_Dashboard_UI_Style.css | 12 +++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts index 2f43dd9d..b2c642cb 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts @@ -69,6 +69,82 @@ export function createAssetField( return { root, value: () => selected?.id ?? null }; } +/** + * 서명 그리기 칸 — 마우스·펜으로 획을 긋고 PNG(투명 배경)로 넘긴다. + * 파일 업로드와 같은 통로(`POST /company/assets`)를 쓰므로 백엔드는 손대지 않는다. + */ +function createSignaturePad(): { root: HTMLDivElement; toFile: () => Promise } { + 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); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index cd70421d..de0a5d3f 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -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);