/* ============================================================================ * 서명전에 — TDS Highlight 컴포넌트 (스포트라이트 / 코치마크) * · open=true 가 되면 children 영역만 빛이 새어 나오고 화면 전체가 어두워져요. * · 구현 전략: spot 에 box-shadow 0 0 0 100vmax rgba(0,0,0,.7) 로 dim 페인트 * + 별도 fixed 투명 backdrop 으로 외부 클릭(onClick) 캡처. 박스 섀도우는 hit-test * 영향이 없어 자연스럽게 동작해요. * · message 는 root 기준 absolute 배치 — X 정렬은 left/center/right, Y 정렬은 * top(spot 위) / bottom(spot 아래). 정렬을 안 주면 spot 의 화면 위치를 보고 * 자동 결정해요. * · 화살표는 항상 spot 가로 중앙에 절대 배치 — X 정렬에 무관 (TDS 계약). * · delay 는 초 단위 (TDS 스펙). * · onExited 는 close → 애니메이션 종료(220ms) 후 한 번 호출. * * · props (HighlightProps): * open* boolean — 표시 여부 * padding number(px) — spot 내부 padding (기본 0) * delay number(sec) — 표시 전 지연 (기본 0) * highlighterClassname string — spot 추가 className * message string | (props: { color, style }) => ReactElement * messageColor string — 메시지 색 (기본 #ffffff) * messageXAlignment "left" | "center" | "right" — 미지정 시 자동 * messageYAlignment "top" | "bottom" — 미지정 시 자동 * onClick (e) => void — 외부(backdrop) 클릭 * onExited () => void — close 애니메이션 종료 후 * className, style, aria-* ... 루트
패스스루 * children ReactNode — 강조될 영역 * ========================================================================== */ const { useState: __hlUseState, useRef: __hlUseRef, useEffect: __hlUseEffect, useLayoutEffect: __hlUseLayoutEffect, } = React; const HIGHLIGHT_TRANSITION_MS = 220; function Highlight({ open = false, padding = 0, delay = 0, highlighterClassname = "", message, messageColor = "#ffffff", messageXAlignment, messageYAlignment, onClick, onExited, className = "", style, children, ...rest }) { // visible 은 delay/transition 을 반영한 실제 화면 표시 상태. const [visible, setVisible] = __hlUseState(false); const [autoX, setAutoX] = __hlUseState("center"); const [autoY, setAutoY] = __hlUseState("bottom"); const spotRef = __hlUseRef(null); const exitedTimerRef = __hlUseRef(null); const enterTimerRef = __hlUseRef(null); // open prop 변경에 따른 visible 토글 + delay/exit 처리 __hlUseEffect(() => { // 진입 if (open) { // 닫힘 타이머가 떠있으면 먼저 정리 if (exitedTimerRef.current) { clearTimeout(exitedTimerRef.current); exitedTimerRef.current = null; } if (delay > 0) { enterTimerRef.current = setTimeout(() => { setVisible(true); enterTimerRef.current = null; }, delay * 1000); } else { setVisible(true); } return () => { if (enterTimerRef.current) { clearTimeout(enterTimerRef.current); enterTimerRef.current = null; } }; } // 닫힘 if (enterTimerRef.current) { clearTimeout(enterTimerRef.current); enterTimerRef.current = null; } if (visible) { setVisible(false); exitedTimerRef.current = setTimeout(() => { onExited?.(); exitedTimerRef.current = null; }, HIGHLIGHT_TRANSITION_MS + 40); } return undefined; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, delay]); // 자동 정렬 — visible 직후 spot 위치를 보고 결정 __hlUseLayoutEffect(() => { if (!visible || !spotRef.current) return; const r = spotRef.current.getBoundingClientRect(); const vw = typeof window !== "undefined" ? window.innerWidth || document.documentElement.clientWidth : 0; const vh = typeof window !== "undefined" ? window.innerHeight || document.documentElement.clientHeight : 0; if (!messageXAlignment && vw > 0) { const cx = r.left + r.width / 2; if (cx < vw / 3) setAutoX("left"); else if (cx > (vw * 2) / 3) setAutoX("right"); else setAutoX("center"); } if (!messageYAlignment && vh > 0) { // spot 이 화면 위쪽이면 메시지를 아래에, 아래쪽이면 위에 띄움 setAutoY(r.top < vh / 2 ? "bottom" : "top"); } }, [visible, messageXAlignment, messageYAlignment, padding]); const xAlign = messageXAlignment || autoX; const yAlign = messageYAlignment || autoY; // 언마운트 시 타이머 정리 __hlUseEffect(() => { return () => { if (exitedTimerRef.current) clearTimeout(exitedTimerRef.current); if (enterTimerRef.current) clearTimeout(enterTimerRef.current); }; }, []); const handleBackdropClick = (e) => { onClick?.(e); }; // message 해석 — string 또는 함수 let messageNode = null; if (message != null && message !== false) { const messageStyle = { color: messageColor }; if (typeof message === "function") { try { messageNode = message({ color: messageColor, style: messageStyle }); } catch (err) { if (window.console && console.warn) { console.warn("[Highlight] message 함수 호출 실패:", err); } messageNode = null; } } else { messageNode = {message}; } } const rootCls = ["tds-highlight", className].filter(Boolean).join(" "); const spotCls = [ "tds-highlight__spot", visible ? "is-open" : "", highlighterClassname, ] .filter(Boolean) .join(" "); const messageCls = [ "tds-highlight__message", `tds-highlight__message--x-${xAlign}`, `tds-highlight__message--y-${yAlign}`, ] .filter(Boolean) .join(" "); const arrowCls = [ "tds-highlight__arrow", `tds-highlight__arrow--y-${yAlign}`, ] .filter(Boolean) .join(" "); return (
{visible && ( ); } window.Highlight = Highlight; Object.assign(window, { Highlight });