diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts index 05eff5feb..b7ceac66e 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts @@ -137,9 +137,14 @@ export function createSourceView(opts: SourceViewOptions = {}): SourceView { ...f.blocks.map(blockNode), ]), ); - requestAnimationFrame(() => - body.querySelector(".m01lab-src__hit")?.scrollIntoView({ block: "center" }), - ); + // 강조 항을 패널 몸 가운데로 — 몸만 굴림(85-5 · scrollIntoView 는 메인 창까지 굴림) + requestAnimationFrame(() => { + const hit = body.querySelector(".m01lab-src__hit"); + const box = hit?.closest(".ui-side-panels__body"); + if (!hit || !box) return hit?.scrollIntoView({ block: "nearest" }); + const off = hit.getBoundingClientRect().top - box.getBoundingClientRect().top; + box.scrollTop += off - (box.clientHeight - hit.offsetHeight) / 2; + }); }); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts index 9d806e4e9..aab337c36 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts @@ -99,6 +99,7 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel { const panels = createSidePanels({ prefKey: "m02-area-panels", defaultWidth: DEFAULT_WIDTH, + follow: false, // 85-5 — 면적 상세 고정 높이 칸을 채움 · 메인 스크롤과 따로 panels: [ { id: "labor", title: TEXT.labor, body: view.root }, { id: "drawing", title: TEXT.drawing, body: drawing.root }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 5f8b7821c..cf2d81160 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -141,6 +141,7 @@ export function mountLaborDetail( ? null : createSidePanels({ prefKey: "m02-labor-side", + follow: false, // 85-5 — 상세 고정 높이 칸을 채움 · 메인 스크롤과 따로 panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }], onChange: () => showSource(), }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 5fe85ec07..d002e926d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -152,6 +152,30 @@ min-width: 0; } +/* 일위대가 상세(85-5) — 면적 상세와 같은 고정 높이 · 본문 칸만 굴림 · 오른쪽 패널은 칸 높이(따로 스크롤) */ +.m02-main:has(> .m02-main__host > .ui-side-panels-row) { + box-sizing: border-box; + height: calc(100vh - var(--spacing-64)); + min-height: 0; +} + +.m02-main__host:has(> .ui-side-panels-row) { + display: flex; + flex: 1 1 0; + min-height: 0; +} + +.m02-main__host > .ui-side-panels-row { + flex: 1 1 0; + min-width: 0; + min-height: 0; +} + +.m02-main__host > .ui-side-panels-row > :first-child { + min-height: 0; + overflow: auto; +} + .m02-main__empty { margin: 0; padding: var(--spacing-24); diff --git a/resources/tester/test_ui_side_panels.py b/resources/tester/test_ui_side_panels.py index bc508ddb6..353093a1c 100644 --- a/resources/tester/test_ui_side_panels.py +++ b/resources/tester/test_ui_side_panels.py @@ -42,3 +42,24 @@ def test_폭_자르기(): ) # fmt: skip assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == [280, 500, 1360, 280] + + +def test_따라붙기_끔_옵션(): + """85-5 — `follow: false` = followView 안 부름 · 칸 높이 채움(is-fill) · M02 상세 둘만 씀 · M01 그대로.""" + src = PART.read_text(encoding="utf-8") + assert ( + 'if (opts.follow === false) root.classList.add("is-fill");\n else followView(root);' in src + ) + css = PART.with_suffix(".css").read_text(encoding="utf-8") + assert ".ui-side-panels.is-fill {" in css and "overscroll-behavior: contain" in css + m02 = ROOT / "M02_MasterTemplete" + for name in ("M02_MasterTemplete_AreaPanel.ts", "M02_MasterTemplete_Labor_Detail.ts"): + assert "follow: false," in (m02 / name).read_text(encoding="utf-8"), name + m01 = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts").read_text(encoding="utf-8") + assert "follow" not in m01 + style = (m02 / "M02_MasterTemplete_UI_Style.css").read_text(encoding="utf-8") + assert ".m02-main:has(> .m02-main__host > .ui-side-panels-row)" in style + source = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts").read_text( + encoding="utf-8" + ) + assert 'scrollIntoView({ block: "center" })' not in source and "box.scrollTop +=" in source diff --git a/resources/tester/ui_checks/49_오른쪽패널_양식.js b/resources/tester/ui_checks/49_오른쪽패널_양식.js index 0b38e149f..e61229f2c 100644 --- a/resources/tester/ui_checks/49_오른쪽패널_양식.js +++ b/resources/tester/ui_checks/49_오른쪽패널_양식.js @@ -7,6 +7,7 @@ * - handles = 패널 수(M01 2 · 일위대가 1 · 면적 2) · 손잡이 x < 패널 x(왼쪽 가장자리 밖) * - steps[i].open = 누른 패널 하나만 · width 모두 같음(폭 공유) · none.open = [] · none.rootW = 0(손잡이만 남음) * - sticky.tops = [0 · 250 · 400 굴렸을 때 패널 top] — 머리띠(head) 아래 8 에 닿으면 그 자리에 멈춤(250 = 400) · 본문이 짧으면 덜 굴러감(scrolled) · inView = 패널 위아래 끝이 창 안 + * · M02 일위대가 · 면적산출(85-5 · is-fill) = 본문 스크롤 칸이 패널 형제 본문 · tops 셋 같음(이동 0) * - ownScroll: 패널 몸을 굴리면 몸 scrollTop 만 바뀌고 본문 스크롤 그대로(mainMoved 0) * - m01Cells = m02Cells(같은 로직 GC000198 원문 표 칸 수) · 표 둘(7칸 · 2칸) * - m01Look = m02Look(sameLook true) — 같은 클래스 · 글자 크기(16px) · 표 칸 꼴 · tableFit 모두 true(표 높이 = 내용 · 자체 세로 스크롤 없음) @@ -60,7 +61,16 @@ const panel = box(scrollId); const body = $(".ui-side-panels__body", panel); // 본문 스크롤 칸 — 위 칸 중 굴러가는 것 · 없으면 창 · 숨은 탭은 scroll 알림이 안 와 직접 보냄 - let sc = null; + let sc = sp.classList.contains("is-fill") + ? ([...sp.parentElement.querySelectorAll("*")].find((n) => { + const oy = getComputedStyle(n).overflowY; + return ( + !sp.contains(n) && + (oy === "auto" || oy === "scroll") && + n.scrollHeight > n.clientHeight + 1 + ); + }) ?? null) + : null; for (let n = sp.parentElement; n && !sc; n = n.parentElement) { const oy = getComputedStyle(n).overflowY; if ((oy === "auto" || oy === "scroll") && n.scrollHeight > n.clientHeight + 1) sc = n; diff --git a/resources/tester/ui_checks/51-1_오른쪽패널_높이.js b/resources/tester/ui_checks/51-1_오른쪽패널_높이.js index 1a447c13b..bd2ac48ae 100644 --- a/resources/tester/ui_checks/51-1_오른쪽패널_높이.js +++ b/resources/tester/ui_checks/51-1_오른쪽패널_높이.js @@ -2,6 +2,7 @@ * 시작 화면: 패널이 이미 열린 상세 화면 하나(M02 일위대가 상세 · M02 면적산출 · M01 로직 상세 중 하나) · 화면마다 한 번씩 부름 * 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌) * 기대: ok = true · h = want(innerHeight − max(머리띠, 칸 위)) ±2 · 칸이 올라가 붙은 뒤는 innerHeight − 머리띠 · inView = 그대로·처음·중간·끝 모두 true + * · M02 일위대가 · 면적산출 상세(85-5 · follow:false = is-fill) — want = 칸 높이 · 본문 스크롤 칸 = 패널 형제 본문 · moved(처음~끝 top 차) = 0 */ (async () => { const w = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -10,7 +11,17 @@ ); if (!sp) return JSON.stringify({ error: "펴진 패널 없음" }); const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0; + const fill = sp.classList.contains("is-fill"); + const scrolls = (n) => + ["auto", "scroll"].includes(getComputedStyle(n).overflowY) && + n.scrollHeight > n.clientHeight + 1; const scroller = (() => { + // is-fill(85-5) — 메인 = 같은 칸의 본문(패널 밖) + if (fill) + return ( + [...sp.parentElement.querySelectorAll("*")].find((n) => !sp.contains(n) && scrolls(n)) ?? + document.scrollingElement + ); for (let n = sp.parentElement; n; n = n.parentElement) { const y = getComputedStyle(n).overflowY; if ((y === "auto" || y === "scroll") && n.scrollHeight > n.clientHeight + 1) return n; @@ -23,9 +34,9 @@ window.dispatchEvent(new Event("scroll")); // 뒤에 가린 탭은 스크롤 알림이 안 와서 직접 await w(150); const r = sp.getBoundingClientRect(); - const want = Math.round( - innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top), - ); + const want = fill + ? Math.round(sp.parentElement.getBoundingClientRect().height) + : Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top)); const body = sp.querySelector(".ui-side-panels__panel:not([hidden]) .ui-side-panels__body"); return { name, @@ -43,7 +54,9 @@ const still = { name: "그대로", h: Math.round(r0.height), - want: Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top)), + want: fill + ? Math.round(sp.parentElement.getBoundingClientRect().height) + : Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top)), inView: r0.top >= head - 2 && r0.bottom <= innerHeight + 2, }; const rows = [ @@ -58,6 +71,11 @@ head: Math.round(head), scrollMax: Math.round(max), rows, - ok: rows.every((r) => Math.abs(r.h - r.want) <= 2 && r.inView), + fill, + moved: + Math.max(...rows.slice(1).map((r) => r.top)) - Math.min(...rows.slice(1).map((r) => r.top)), + ok: + rows.every((r) => Math.abs(r.h - r.want) <= 2 && r.inView) && + (!fill || rows.slice(1).every((r) => r.top === rows[1].top)), }); })(); diff --git a/ui_template/ui_template_side_panels.css b/ui_template/ui_template_side_panels.css index 77f3f306f..ca85e4cac 100644 --- a/ui_template/ui_template_side_panels.css +++ b/ui_template/ui_template_side_panels.css @@ -28,6 +28,21 @@ margin-left: calc(var(--spacing-24) + var(--spacing-8)); } +/* follow:false(85-5) — 부르는 칸(고정 높이) 높이를 채움 · 패널 내용이 칸 높이를 늘리지 않음 */ +.ui-side-panels.is-fill { + align-self: stretch; + width: var(--ui-side-panels-width, 420px); + height: auto; + min-height: 240px; +} + +.ui-side-panels.is-fill > .ui-side-panels__panel { + position: absolute; + inset: 0; + width: auto; + height: auto; +} + /* 모두 접힘 — 폭 0 · 손잡이만 왼쪽 밖에 남음 */ .ui-side-panels.is-collapsed { width: 0; @@ -113,4 +128,5 @@ min-height: 0; padding: var(--spacing-12); overflow: auto; + overscroll-behavior: contain; /* 몸 끝에서 메인으로 이어 굴리지 않음(85-5) */ } diff --git a/ui_template/ui_template_side_panels.ts b/ui_template/ui_template_side_panels.ts index 93aa4c17c..e60217c44 100644 --- a/ui_template/ui_template_side_panels.ts +++ b/ui_template/ui_template_side_panels.ts @@ -4,6 +4,7 @@ * 좌측 패널과 같은 접기 양식 — 패널 왼쪽 가장자리 손잡이(공용 createWorkflowPanelHandle · 오른쪽으로 접힘) * 패널마다 손잡이 하나(이름표 세로) · 누르면 폄/접음 · 모두 접히면 손잡이만 남음 * 편 패널 = 머리(제목) + 몸 · 왼쪽 경계 끌기(화살표 키) · 보이는 칸 안에 붙어 몸만 따로 스크롤(followView) + * `follow: false`(85-5) = followView 안 씀 · 부르는 칸(고정 높이) 높이를 채움 · 메인 스크롤과 따로(M02 상세) * 열린 패널 · 폭 = 부르는 쪽 취향 키(`aislo:pref:{prefKey}`) * 부르기: const sp = createSidePanels({ prefKey, panels: [{ id, title, body }] }) → * 본문과 함께 `.ui-side-panels-row` 칸에 [본문, sp.root] 로 넣음 @@ -42,6 +43,8 @@ export interface SidePanelsOpts { onChange?(open: string | null): void; /** 폭이 바뀜(끌기 · 화살표) */ onResize?(px: number): void; + /** 보이는 칸 따라 붙음(followView) · 기본 참 · 거짓 = 부르는 칸 높이를 채움(칸이 고정 높이 · 85-5) */ + follow?: boolean; } export interface SidePanels { @@ -166,7 +169,8 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels { } draw(); - followView(root); + if (opts.follow === false) root.classList.add("is-fill"); + else followView(root); return { root, open, current: () => now, width: () => width }; }