From af85dea7148b521c4f20217ddf6ac21dda9ac9af Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 06:37:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui=5Ftemplate):=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=ED=8C=A8=EB=84=90=20=EB=81=8C=EC=96=B4=20?= =?UTF-8?q?=EC=98=AE=EA=B8=B0=EA=B8=B0=C2=B7=EC=97=B4=EB=A6=BC=20=EB=B0=A9?= =?UTF-8?q?=ED=96=A5=20=EC=9E=90=EB=8F=99=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 헤더를 잡아 끌어 자리 이동, 이동 범위는 상단 헤더 아래 화면 안으로 clamp - 아래 공간 부족 시 `is-flip-up` 으로 헤더 위로 펼침(바닥 기준 정렬) - 자리는 sessionStorage 세션 캐시에만 보관, 접힘/펴짐 뒤 방향 재판정 - threshold 4px 초과 시에만 포인터 캡처 — pointerdown 캡처는 click 대상을 제목에서 헤더로 바꿔 접기/펴기 토글을 죽임(실측) - 이동은 window 에서 청취 — B07 CAD iframe 위로 커서가 나가도 안 끊김 검증: 공용 브라우저 B03·B05·B07 실측(이동 오차 1px 이내, clamp·뒤집힘·토글 정상), tsc --noEmit 통과 Co-Authored-By: Claude Opus 5 (1M context) --- ui_template/ui_template_overlay.css | 15 +++ ui_template/ui_template_overlay.ts | 10 ++ ui_template/ui_template_overlay_drag.ts | 169 ++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 ui_template/ui_template_overlay_drag.ts diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index b139123a..e4372eb4 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -59,6 +59,21 @@ width: calc(var(--wf-left-panel-width) / 2); } +/* 끌어 옮기기 (2026-09-04) — 헤더를 잡아 끈다. 자리를 옮기면 인라인 left/top이 + 위 고정값을 덮어쓴다. 아래 공간이 모자라면 `is-flip-up`으로 헤더 위로 펼친다. */ +.ui-workflow-overlay__panel--progress .ui-workflow-overlay__header { + cursor: move; + touch-action: none; +} + +.ui-workflow-overlay__panel--progress.is-flip-up { + flex-direction: column-reverse; +} + +.ui-workflow-overlay__panel--progress.is-dragging { + user-select: none; +} + .ui-workflow-overlay__header { display: flex; align-items: center; diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 3deb4200..d2736098 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -1,8 +1,10 @@ import "./ui_template_overlay.css"; import { t } from "./ui_template_locale"; +import { makePanelDraggable } from "./ui_template_overlay_drag"; const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open"; const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open"; +const PROGRESS_OVERLAY_POSITION_KEY = "frd_workflow_progress_overlay_pos"; export interface WorkflowOverlayOptions { title: string; @@ -146,6 +148,8 @@ function createPanel( root.append(body); } + let dragHandle: { refresh: () => void } | null = null; + function setOpen(isOpen: boolean): void { root.classList.toggle("is-collapsed", !isOpen); if (panelHandle) panelHandle.setOpen(isOpen); @@ -156,6 +160,8 @@ function createPanel( toggle.setAttribute("aria-expanded", String(isOpen)); } sessionStorage.setItem(storageKey, String(isOpen)); + // 펼친 뒤에는 아래 공간이 모자랄 수 있다 — 열리는 방향을 다시 잡는다. + if (dragHandle) requestAnimationFrame(() => dragHandle?.refresh()); onOpenChange?.(isOpen); } @@ -174,6 +180,10 @@ function createPanel( }); toggle.addEventListener("click", toggleOpen); setOpen(readOpenState(storageKey)); + // 진행단계 패널만 사용자가 자리를 옮긴다(제목 패널은 좌측 도킹 사이드바). + if (variant === "progress") { + dragHandle = makePanelDraggable(root, header, PROGRESS_OVERLAY_POSITION_KEY); + } return { root, setOpen }; } diff --git a/ui_template/ui_template_overlay_drag.ts b/ui_template/ui_template_overlay_drag.ts new file mode 100644 index 00000000..adb456bd --- /dev/null +++ b/ui_template/ui_template_overlay_drag.ts @@ -0,0 +1,169 @@ +/** + * 진행단계 패널 끌어 옮기기 (2026-09-04 사용자 지시). + * + * 패널은 `position: fixed`라 좌표계가 곧 뷰포트다. 이동 범위는 **상단 헤더 아래 + * 화면 안**으로 잡아, 패널을 화면 밖으로 흘려 다시 못 잡는 상태를 원천 차단한다. + * + * 헤더에는 이미 제목 클릭 = 접기/펴기 토글이 붙어 있으므로(`ui_template_overlay.ts`) + * 이동 threshold 4px를 두고, 넘으면 드래그로 보고 뒤따르는 click 한 번을 삼킨다. + * 안 그러면 옮길 때마다 패널이 접힌다. + * + * 자리는 세션에만 남긴다(5장 데이터 3층 — 화면 조작값은 캐시 몫). + */ + +const DRAG_THRESHOLD_PX = 4; +/** 상단 공용 헤더 높이(--spacing-64) — 그 위로는 못 올라간다. */ +const MIN_TOP_PX = 64; +/** 화면 가장자리에서 남겨 둘 여백. */ +const EDGE_GAP_PX = 8; + +export interface PanelDragHandle { + /** 펼침/접힘이 바뀐 뒤 열리는 방향을 다시 판정한다. */ + refresh: () => void; +} + +interface PanelPosition { + left: number; + top: number; +} + +function readPosition(key: string): PanelPosition | null { + const raw = sessionStorage.getItem(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 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 apply(next: PanelPosition): void { + 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`; + } + sessionStorage.setItem(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; + // 놓는 시점에 열리는 방향을 다시 판정한다. + if (position) apply(clamp(position.left, position.top)); + swallowNextClick(); + } + + window.addEventListener("pointerup", endDrag); + window.addEventListener("pointercancel", endDrag); + + // 만들어진 직후에는 아직 DOM에 붙기 전이라 실측 크기가 0이다 — 한 프레임 뒤에 앉힌다. + if (position) requestAnimationFrame(() => position && apply(clamp(position.left, position.top))); + + return { + refresh: () => { + if (position) apply(clamp(position.left, position.top)); + }, + }; +}