feat(ui_template): 진행단계 패널 끌어 옮기기·열림 방향 자동 전환
- 헤더를 잡아 끌어 자리 이동, 이동 범위는 상단 헤더 아래 화면 안으로 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PanelPosition>;
|
||||
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<HTMLElement>(".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));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user