feat(b09): 연결 정보 팝업 Esc = 모두 닫힘 · 맨 위 팝업 밖 누름(아래 팝업 · 빈 곳 · 톡 · 우클릭) = 모두 닫힘 · 그 누름은 아래 화면에 안 넘김 · 맨 위 팝업 안 누름 · 끌기 그대로 (308-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-13 18:01:49 +09:00
co-authored by Claude Opus 5.5
parent c06ad0835f
commit 4ecf51c223
2 changed files with 69 additions and 10 deletions
+26 -3
View File
@@ -5,7 +5,8 @@
* 창 = 열 제목(머리 여러 줄 그대로) + 강조 범위 줄 전체 · 표 짜기 = `…_UI_Builders`(화면과 같은 옵션)
* 강조 범위 = 설계내역서 그 줄 + 자손 · 묶음 탭 호표 구간 · 그 밖 한 줄(하이퍼링크 강조와 같음)
* 자리 = 누른 줄 아래(모자라면 위 · 화면 안) · 너비 = 표 너비(최대 화면 90%) · 넘치면 창 안 스크롤
* 창 안 링크 줄 누름 = 이어진 창(겹쳐 위에 · 그 줄 곁) · Esc · × = 맨 위 하나 · 탭 바꿈 · 주소 바뀜 = 모두
* 창 안 링크 줄 누름 = 이어진 창(겹쳐 위에 · 그 줄 곁) · × = 그 창부터 위로 · 탭 바꿈 · 주소 바뀜 = 모두
* Esc · 맨 위 창 밖 누름(아래 창 · 빈 곳 · 톡) = 모두(308) — 그 누름은 닫기만(아래 화면에 안 넘김)
* 머리 [탭으로] = 하이퍼링크처럼 옮김(모두 닫음) · 창 안 = 보기 전용(고칠 칸 · 메뉴 없음)
* ========================================================================== */
@@ -67,6 +68,8 @@ export function popupPlace(
/** 열린 창(아래 → 위) */
const stack: HTMLElement[] = [];
let listening = false;
/** 바깥 누름으로 닫은 뒤 따라오는 click · 우클릭 메뉴를 먹음(308) */
let eat = false;
/** from 번째 창부터 위로 모두 닫음(0 = 모두) */
export function closePopups(from = 0): void {
@@ -78,17 +81,37 @@ export const popupCount = (): number => stack.length;
function listen(): void {
if (listening) return;
listening = true;
// Esc = 맨 위 하나(뒤 화면의 Esc 풂보다 먼저 · 창 있을 때만 가로챔)
// Esc = 모두(뒤 화면의 Esc 풂보다 먼저 · 창 있을 때만 가로챔)
window.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || !stack.length) return;
event.preventDefault();
event.stopPropagation();
closePopups(stack.length - 1);
closePopups();
},
true,
);
// 맨 위 창 밖 누름 = 모두 닫음 · 그 누름은 아래로 안 넘김(308)
window.addEventListener(
"pointerdown",
(event) => {
eat = !!stack.length && !stack[stack.length - 1].contains(event.target as Node);
if (!eat) return;
event.preventDefault();
event.stopPropagation();
closePopups();
},
true,
);
const swallow = (event: Event): void => {
if (!eat) return;
eat = false;
event.preventDefault();
event.stopPropagation();
};
window.addEventListener("click", swallow, true);
window.addEventListener("contextmenu", swallow, true);
window.addEventListener("hashchange", () => closePopups());
}
+43 -7
View File
@@ -165,15 +165,25 @@ E.prototype.getBoundingClientRect = function () {
return this.rect ?? { left: 0, top: 0, right: 0, bottom: 0, width: 400, height: 300 };
};
document.body = new E("body");
const winKeys = [];
const hashFns = [];
const winFns = { keydown: [], hashchange: [], pointerdown: [], click: [], contextmenu: [] };
const winKeys = winFns.keydown;
const hashFns = winFns.hashchange;
globalThis.window = { innerWidth: 1200, innerHeight: 800,
addEventListener: (t, f) => (t === "keydown" ? winKeys : hashFns).push(f) };
addEventListener: (t, f) => winFns[t].push(f) };
const esc = () => {
const ev = { key: "Escape", stopped: false, preventDefault() {}, stopPropagation() { ev.stopped = true; } };
winKeys.forEach((f) => f(ev));
return ev.stopped;
};
// 308 창 밖 누름 — pointerdown → click(또는 우클릭 메뉴) · 먹힘 = stopped
const press = (target, type = "click") => {
const ev = (t) => ({ type: t, target, stopped: false, preventDefault() {}, stopPropagation() { this.stopped = true; } });
const down = ev("pointerdown");
winFns.pointerdown.forEach((f) => f(down));
const up = ev(type);
winFns[type].forEach((f) => f(up));
return [down.stopped, up.stopped];
};
const tables = [];
const createDataTable = (o) => {
const root = document.createElement("div");
@@ -259,16 +269,16 @@ def test_이어진_창_겹침_Esc_차례():
"\nconst b = boxes();"
"\nconst dg = rows(2);"
"\nconst e1 = esc(); const c = boxes();"
"\nconst e2 = esc(); const e3 = esc(); const e4 = esc();"
"\nconsole.log(JSON.stringify({ a, b, dg, top2: second.style.top, c, e: [e1, e2, e3, e4],"
"\nconst e2 = esc();"
"\nconsole.log(JSON.stringify({ a, b, dg, top2: second.style.top, c, e: [e1, e2],"
" d: boxes(), gone }));"
)
assert out["a"] == ["bill"]
assert out["b"] == ["bill", "labor", "dg"] # 겹쳐 위에(뒤 DOM = 위)
assert out["dg"] == ["D1", "D1|1", "D1|합계"] # 호표 구간
assert out["top2"] == "362px" # 그 줄(창 안 2번째 줄) 곁
assert out["c"] == ["bill", "labor"] # Esc = 맨 위 하나
assert out["e"] == [True, True, True, False] # 창 없으면 Esc 안 가로챔
assert out["c"] == [] # 308 Esc = 모두
assert out["e"] == [True, False] # 창 없으면 Esc 안 가로챔
assert out["d"] == [] and out["gone"] == []
@@ -295,6 +305,32 @@ def test_닫기_탭으로_새_누름_주소():
assert out["e"] == [[], 2]
def test_308_맨_위_창_밖_누름_모두_닫힘_누름_먹음():
out = _popup(
"\nconst outside = document.createElement('td'); document.body.append(outside);"
"\nconst open3 = () => { openRowPopup({ ...base, link: { tab: 'bill', key: 'b1' }, anchor });"
" tables[tables.length - 1].onCellClick(1, 'name');"
" tables[tables.length - 1].onCellClick(0, 'name'); };"
"\nconst pops = () => boxes().filter(Boolean);"
"\nconst top = () => document.body.children[document.body.children.length - 1];"
"\nconst under = () => document.body.children.filter((b) => b.dataset.b09Popup)[1];"
"\nopen3(); const inTop = press(top().children[1]); const a = pops();" # 맨 위 창 안 = 그대로
"\nconst right0 = press(top().children[0], 'contextmenu'); const a2 = pops();"
"\nconst low = press(under().children[1]); const b = pops();" # 아래 창 누름
"\nopen3(); const blank = press(outside); const c = pops();" # 쪽 빈 곳 · 톡
"\nopen3(); const right = press(outside, 'contextmenu'); const d = pops();" # 우클릭
"\nconst none = press(outside);" # 창 없음 = 안 가로챔
"\nconsole.log(JSON.stringify({ inTop, a, right0, a2, low, b, blank, c, right, d, none, gone }));"
)
full = ["bill", "labor", "dg"]
assert out["inTop"] == [False, False] and out["a"] == full # 맨 위 창 안 = 그대로
assert out["right0"] == [False, False] and out["a2"] == full
assert out["low"] == [True, True] and out["b"] == [] # 모두 닫힘 · 그 누름 먹음
assert out["blank"] == [True, True] and out["c"] == []
assert out["right"] == [True, True] and out["d"] == []
assert out["none"] == [False, False] and out["gone"] == []
def test_창_자리_화면_안():
out = _popup(
"\nconst v = { width: 1200, height: 800 };"