/* ============================================================================ * 서명전에 — TDS FullSecureKeypad 컴포넌트 * · 비밀번호·계정 로그인처럼 보안이 중요한 입력을 받을 때 쓰는 풀 키패드. * 숫자(1~0) + QWERTY 알파벳 + 한글 듀얼 라벨 + 하단 특수/Space/입력 완료. * * · 보안 특성: * 1. **랜덤 빈 셀** — 알파벳 자리 사이에 일부 빈 셀이 섞여 들어가요. * 어깨너머로 지켜보는 제3자가 키 위치만 외우기 어렵게 해요. * ref.reorderEmptyCells() 로 재셔플 가능. * 2. **대소문자 토글** — Shift 키로 대문자/소문자 전환. * 3. **한글 동반 표시** — 한국어 사용자가 영문 키보드 자판을 한글로 * 떠올리지 않아도 되게, `q(ㅂ)` 처럼 각 알파벳 옆에 한글을 작게 병기. * * · ref 노출: * reorderEmptyCells(): void — 빈 셀 위치를 새로 섞어요. * element: HTMLDivElement | null — 루트 DOM 노드 (포커스/스크롤 제어용). * * · props (FullSecureKeypadProps): * onKeyClick* (value: string) => void — 알파벳/숫자 키 * (대문자 여부는 호출 시점 shift 상태 반영) * onBackspaceClick* () => void — 지우기 * onSpaceClick* () => void — Space * onSubmit* () => void — "입력 완료" * submitDisabled? boolean — 기본 false * submitButtonText? string — 기본 "입력 완료" * backspaceAriaLabel? string — 기본 "한 글자 지우기" * spaceAriaLabel? string — 기본 "띄어쓰기" * specialAriaLabel? string — 기본 "특수문자" * onSpecialClick? () => void — "특수" 키. 생략 시 비활성. * className, style — 컨테이너 속성 패스스루 * * · window.FullSecureKeypad 로 글로벌 등록 (React.forwardRef). * ========================================================================== */ const { useState: __fskUseState, useRef: __fskUseRef, useImperativeHandle: __fskUseImperativeHandle, useCallback: __fskUseCallback, } = React; /* ── 알파벳 ↔ 한글 듀얼 라벨 매핑 (두벌식 표준) ─────────────────────── */ const _FSK_HANGUL_MAP = { q: "ㅂ", w: "ㅈ", e: "ㄷ", r: "ㄱ", t: "ㅅ", y: "ㅛ", u: "ㅕ", i: "ㅑ", o: "ㅐ", p: "ㅔ", a: "ㅁ", s: "ㄴ", d: "ㅇ", f: "ㄹ", g: "ㅎ", h: "ㅗ", j: "ㅓ", k: "ㅏ", l: "ㅣ", z: "ㅋ", x: "ㅌ", c: "ㅊ", v: "ㅍ", b: "ㅠ", n: "ㅜ", m: "ㅡ", }; /* ── 기본 키 배열 ────────────────────────────────────────────────────── * 각 행은 항상 10칸. 알파벳이 10칸을 못 채우면 뒷자리에 __slot 을 둬요. * __slot 은 ref.reorderEmptyCells() 로 셔플 가능한 "빈 자리 후보". * ─────────────────────────────────────────────────────────────────── */ const _FSK_NUMBER_ROW = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]; const _FSK_QWERTY_ROW_1 = ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"]; // 10 const _FSK_QWERTY_ROW_2 = ["a", "s", "d", "f", "g", "h", "j", "k", "l"]; // 9 const _FSK_QWERTY_ROW_3 = ["z", "x", "c", "v", "b", "n", "m"]; // 7 /** 공 셀 랜덤 배치: cells 개의 후보 슬롯에 nEmpty 개를 true 로 마크 */ function _fskRandomEmptyMask(slots, nEmpty) { const idx = []; for (let i = 0; i < slots; i += 1) idx.push(i); // Fisher-Yates for (let i = idx.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); const tmp = idx[i]; idx[i] = idx[j]; idx[j] = tmp; } const emptySet = new Set(idx.slice(0, Math.min(nEmpty, slots))); const mask = []; for (let i = 0; i < slots; i += 1) mask.push(emptySet.has(i)); return mask; } /* ── Backspace 아이콘 (TDS mono, 24×24, currentColor) ── */ function _FskBackspaceIcon() { return ( ); } /* ── Shift 아이콘 ── */ function _FskShiftIcon({ active }) { return ( ); } const _FullSecureKeypadInner = (props, ref) => { const { onKeyClick, onBackspaceClick, onSpaceClick, onSubmit, onSpecialClick, submitDisabled = false, submitButtonText = "입력 완료", backspaceAriaLabel = "한 글자 지우기", spaceAriaLabel = "띄어쓰기", specialAriaLabel = "특수문자", className = "", style, ...rest } = props; // 콜백 누락 경고 if (typeof onKeyClick !== "function" && window.console && console.warn) { console.warn("[FullSecureKeypad] `onKeyClick` 은 필수예요."); } if (typeof onBackspaceClick !== "function" && window.console && console.warn) { console.warn("[FullSecureKeypad] `onBackspaceClick` 은 필수예요."); } if (typeof onSpaceClick !== "function" && window.console && console.warn) { console.warn("[FullSecureKeypad] `onSpaceClick` 은 필수예요."); } if (typeof onSubmit !== "function" && window.console && console.warn) { console.warn("[FullSecureKeypad] `onSubmit` 은 필수예요."); } const rootRef = __fskUseRef(null); const [shift, setShift] = __fskUseState(false); // 각 행의 빈 셀 마스크를 state 로 보관 → reorderEmptyCells() 로 갱신 const [masks, setMasks] = __fskUseState(() => ({ // 첫 행(숫자)은 10칸 가득 — 빈 셀 없음. 그 외 빈 자리에만 마스크 의미. // row1: qwerty 10자 가득. 빈 셀은 뒤 추가 없음 → 0. // row2: 9자 + 1빈. 행 10칸 기준 뒤쪽 slots 1 개 → 항상 1. // row3: 7자 + shift(좌) + backspace(우) + 1빈 = 10. slot 1. // 보안 목적 셔플은 2행/3행 내 "어느 자리에 진짜 알파벳을 놓을지"를 재배치. row2Order: _fskShuffleIndices(10), row3Order: _fskShuffleIndices(10), })); function _fskShuffleIndices(n) { const a = []; for (let i = 0; i < n; i += 1) a.push(i); for (let i = a.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); const t = a[i]; a[i] = a[j]; a[j] = t; } return a; } const reorderEmptyCells = __fskUseCallback(() => { setMasks({ row2Order: _fskShuffleIndices(10), row3Order: _fskShuffleIndices(10), }); }, []); __fskUseImperativeHandle(ref, () => ({ reorderEmptyCells, get element() { return rootRef.current; }, }), [reorderEmptyCells]); const handleKey = (raw) => { if (typeof onKeyClick !== "function") return; // shift 활성 & 알파벳이면 대문자 if (shift && /^[a-z]$/.test(raw)) { onKeyClick(raw.toUpperCase()); } else { onKeyClick(raw); } }; const rootCls = "tds-fskeypad" + (className ? " " + className : ""); /** 행 렌더: 10칸 고정. * items: { kind: 'letter', value } | { kind: 'empty' } — row2/row3 에 사용. * order: 0~9 인덱스 배열. 진짜 아이템을 이 순서로 배치해요. */ function renderLetterRow(rowKey, items, order) { // order 의 앞부분 items.length 개가 "진짜 키가 들어갈 자리"를 결정. const placed = new Array(10).fill(null); for (let i = 0; i < items.length; i += 1) { placed[order[i]] = items[i]; } return placed.map((item, idx) => { if (item === null) { return (
); } const lower = item; const display = shift ? lower.toUpperCase() : lower; const hangul = _FSK_HANGUL_MAP[lower] || ""; return ( ); }); } return (