/** * 진행단계 패널 끌어 옮기기 (2026-09-04 사용자 지시). * * 패널은 `position: fixed`라 좌표계가 곧 뷰포트다. 이동 범위는 **상단 헤더 아래 * 화면 안**으로 잡아, 패널을 화면 밖으로 흘려 다시 못 잡는 상태를 원천 차단한다. * * 헤더에는 이미 제목 클릭 = 접기/펴기 토글이 붙어 있으므로(`ui_template_overlay.ts`) * 이동 threshold 4px를 두고, 넘으면 드래그로 보고 뒤따르는 click 한 번을 삼킨다. * 안 그러면 옮길 때마다 패널이 접힌다. * * 자리는 세션에만 남긴다(5장 데이터 3층 — 화면 조작값은 캐시 몫). * * ⚠ 좁은 폭에서는 **끌어 둔 자리를 잠시 물린다**(2026-09-08). 인라인 좌표는 미디어 * 규칙보다 세서, 넓은 화면에서 끌어 둔 자리가 좁은 화면에서도 그대로 남아 본문을 * 덮었다(실측: 본문에 4px 만 남았다). ⇒ 좁아지면 인라인 좌표를 **지우지 않고 잠깐 * 떼어** CSS 자리로 보내고, 넓어지면 **끌어 둔 자리를 되찾는다.** * ⚠ 다만 **좁은 폭에서 사용자가 직접 끌면 그 뜻이 이긴다** — 그때는 규칙이 다시 덮지 * 않는다. 그 자리는 저장하지 않아 넓은 화면 자리를 밀어내지도 않는다. */ import { readByKey, writeByKey } from "../A00_Common/b_page_state"; const DRAG_THRESHOLD_PX = 4; /** 상단 공용 헤더 높이(--spacing-64) — 그 위로는 못 올라간다. */ const MIN_TOP_PX = 64; /** 화면 가장자리에서 남겨 둘 여백. */ const EDGE_GAP_PX = 8; /** 좁은 폭 경계 — `ui_template_overlay.css` 의 미디어 규칙과 같은 값이어야 한다. */ const NARROW_MAX_PX = 860; export interface PanelDragHandle { /** 펼침/접힘이 바뀐 뒤 열리는 방향을 다시 판정한다. */ refresh: () => void; } interface PanelPosition { left: number; top: number; } function readPosition(key: string): PanelPosition | null { const raw = readByKey(key); if (!raw) return null; try { const parsed = JSON.parse(raw) as Partial; if (typeof parsed?.left !== "number" || typeof parsed?.top !== "number") return null; return { left: parsed.left, top: parsed.top }; } catch { return null; } } export function makePanelDraggable( root: HTMLElement, header: HTMLElement, storageKey: string, ): PanelDragHandle { /** 끌어 둔 자리(넓은 화면 기준) — 좁은 폭에서도 **지우지 않는다.** */ let position: PanelPosition | null = readPosition(storageKey); /** 좁은 폭에서 사용자가 직접 끌어 둔 자리 — 저장하지 않고 그 폭에서만 쓴다. */ let narrowPosition: PanelPosition | null = null; let pointerId: number | null = null; let startX = 0; let startY = 0; let baseLeft = 0; let baseTop = 0; let moved = false; function bodyHeight(): number { if (root.classList.contains("is-collapsed")) return 0; const body = root.querySelector(".ui-workflow-overlay__body"); return body ? body.offsetHeight : 0; } /** 헤더 왼쪽 위 좌표를 화면 안으로 가둔다. */ function clamp(left: number, top: number): PanelPosition { const width = root.offsetWidth; const headerHeight = header.offsetHeight; const maxLeft = Math.max(0, window.innerWidth - width - EDGE_GAP_PX); const maxTop = Math.max(MIN_TOP_PX, window.innerHeight - headerHeight - EDGE_GAP_PX); return { left: Math.min(Math.max(left, EDGE_GAP_PX), maxLeft), top: Math.min(Math.max(top, MIN_TOP_PX), maxTop), }; } /** * 자리를 인라인 좌표로 박고, 아래 공간이 모자라면 헤더 **위로** 펼치게 뒤집는다. * 뒤집을 때는 `bottom` 기준으로 잡아 헤더가 놓아 둔 자리에 그대로 남는다. */ function isNarrow(): boolean { return window.innerWidth <= NARROW_MAX_PX; } /** 인라인 좌표를 뗀다 — CSS(미디어 규칙)가 정한 자리로 돌아간다. 저장값은 그대로 둔다. */ function detach(): void { root.style.left = ""; root.style.right = ""; root.style.top = ""; root.style.bottom = ""; root.classList.remove("is-flip-up"); } /** 지금 폭에 맞는 자리를 앉힌다. 좁은 폭에서 직접 끈 자리가 있으면 그것이 이긴다. */ function settle(): void { if (isNarrow()) { if (narrowPosition) apply(clamp(narrowPosition.left, narrowPosition.top), false); else detach(); return; } if (position) apply(clamp(position.left, position.top), false); else detach(); } function apply(next: PanelPosition, persist = true): void { if (persist && isNarrow()) narrowPosition = next; else if (persist) position = next; const headerHeight = header.offsetHeight; const body = bodyHeight(); const spaceBelow = window.innerHeight - (next.top + headerHeight) - EDGE_GAP_PX; const spaceAbove = next.top - MIN_TOP_PX; const flip = body > spaceBelow && spaceAbove > spaceBelow; root.style.left = `${next.left}px`; root.style.right = "auto"; root.classList.toggle("is-flip-up", flip); if (flip) { root.style.top = "auto"; root.style.bottom = `${Math.max(window.innerHeight - next.top - headerHeight, 0)}px`; } else { root.style.bottom = "auto"; root.style.top = `${next.top}px`; } // ⚠ 좁은 폭에서 끈 자리는 **저장하지 않는다** — 저장하면 넓은 화면에서 맞춰 둔 // 자리를 밀어낸다. 그 폭에 있는 동안만 쓴다. if (persist && !isNarrow()) writeByKey(storageKey, JSON.stringify(next)); } function swallowNextClick(): void { const handler = (event: MouseEvent): void => { event.stopPropagation(); event.preventDefault(); }; window.addEventListener("click", handler, { capture: true, once: true }); // 화면 밖에서 손을 떼면 click 이 안 온다 — 남은 감시자가 다음 진짜 클릭을 // 먹지 않도록 곧 걷어낸다. click 은 pointerup 과 같은 입력 처리 차례에 오므로 // 타이머(0ms)는 늘 그 뒤에 돈다. window.setTimeout(() => window.removeEventListener("click", handler, true), 0); } header.addEventListener("pointerdown", (event: PointerEvent) => { if (event.button !== 0) return; const rect = root.getBoundingClientRect(); pointerId = event.pointerId; startX = event.clientX; startY = event.clientY; baseLeft = rect.left; // 뒤집힌 상태에서도 기준은 늘 헤더의 위쪽 변이다. baseTop = header.getBoundingClientRect().top; moved = false; // 여기서 포인터를 잡으면 뒤따르는 click 의 대상이 제목에서 헤더로 바뀌어 // 접기/펴기 토글이 죽는다(2026-09-04 실측). 실제로 끌기 시작한 뒤에 잡는다. }); // 포인터 이동은 **창 전체**에서 듣는다. 헤더에서만 들으면 커서가 패널 밖으로 // 나가는 순간 이동이 끊기고, 헤더에 포인터를 잡아 두면(setPointerCapture) 뒤따르는 // click 의 대상이 제목에서 헤더로 바뀌어 접기/펴기 토글이 죽는다(2026-09-04 실측). window.addEventListener("pointermove", (event: PointerEvent) => { if (pointerId !== event.pointerId) return; const dx = event.clientX - startX; const dy = event.clientY - startY; if (!moved && Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX) return; if (!moved) { moved = true; // 끌기가 실제로 시작된 뒤에만 포인터를 붙잡는다. B07 CAD 화면은 iframe 이라 // 커서가 그 위로 들어가면 바깥 window 가 이동을 못 받는다(2026-09-04 실측). // pointerdown 시점에 잡으면 뒤따르는 click 대상이 헤더로 바뀌어 토글이 죽는다. try { header.setPointerCapture(event.pointerId); } catch { /* 이미 놓친 포인터면 그대로 진행 */ } } root.classList.add("is-dragging"); apply(clamp(baseLeft + dx, baseTop + dy)); }); function endDrag(event: PointerEvent): void { if (pointerId !== event.pointerId) return; if (moved && header.hasPointerCapture(pointerId)) header.releasePointerCapture(pointerId); pointerId = null; root.classList.remove("is-dragging"); if (!moved) return; // 놓는 시점에 열리는 방향을 다시 판정한다. const held = isNarrow() ? narrowPosition : position; if (held) apply(clamp(held.left, held.top)); swallowNextClick(); } window.addEventListener("pointerup", endDrag); window.addEventListener("pointercancel", endDrag); // 만들어진 직후에는 아직 DOM에 붙기 전이라 실측 크기가 0이다 — 한 프레임 뒤에 앉힌다. requestAnimationFrame(settle); // 창 폭이 바뀌면 다시 판정한다 — 좁아지면 물러나고 넓어지면 끌어 둔 자리를 되찾는다. let pending = 0; window.addEventListener("resize", () => { if (pending) return; pending = requestAnimationFrame(() => { pending = 0; settle(); }); }); return { refresh: settle, }; }