Merge remote-tracking branch 'origin/dev' into sub_laptop_5

This commit is contained in:
2026-10-05 16:23:07 +09:00
9 changed files with 526 additions and 77 deletions
+9 -3
View File
@@ -108,11 +108,14 @@ _NUMBER = re.compile(r"(?<![\d-])\d+(?:-\d+)+(?![\d-])")
def _blocks(lines: list[str]) -> list[dict]:
"""md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌."""
"""md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌 ·
빈 줄 · 주석이 표를 끊음(붙은 표 둘을 한 표로 안 합침) · `<br>` = 줄바꿈."""
out: list[dict] = []
joinable = False
for line in lines:
text = line.strip()
text = re.sub(r"<br\s*/?>", "\n", line.strip())
if not text or text.startswith("<!--"):
joinable = False
continue
if text.startswith("#"):
out.append({"h": len(text) - len(text.lstrip("#")), "text": text.lstrip("#").strip()})
@@ -120,11 +123,14 @@ def _blocks(lines: list[str]) -> list[dict]:
cells = [c.strip() for c in text.strip("|").split("|")]
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
continue # 머리 · 몸 가름 줄
if not out or "table" not in out[-1]:
if not joinable:
out.append({"table": []})
out[-1]["table"].append(cells)
joinable = True
continue
else:
out.append({"p": text})
joinable = False
return out
@@ -14,7 +14,9 @@
background: none;
}
/* 원문 꼴 한 벌(M01 · M02 같음) — 둘레 글자 크기(M01 14)를 안 따르고 본문 크기 */
.m01lab-src {
font-size: var(--text-body);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
@@ -26,8 +28,6 @@
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
max-height: 80vh;
overflow: auto;
}
.m01lab-src__head {
@@ -51,6 +51,7 @@
font-weight: 600;
}
/* 칸 안 줄바꿈(원문 <br>)만 살리고 저절로 접지 않음 */
.m01lab-src__table td {
white-space: nowrap;
white-space: pre;
}
@@ -68,3 +68,7 @@
계획서는 작성해주고 빠르게 작업해줄 내용이 M01과 M02에서 원문보기가 패널 양식을 사용하지 않고 있음. 추가로 M01페이지의 원문보기에서 md파일을 보기가 깨짐이 있음. M02는 정상. M01의 경우 흐름도와 원분은 우측 패널로 구현되어있음. 좌측 패널과 동일하게 접었다 펴는 양식 제대로 확인할 ㄱ서.
그리고 우측 패널의 내용은 메인 화면의 스크롤과 별개로 동작하게 반영
추가로 원문에서 표는 스크롤로 매우 낮은 공간만 보이고 스크롤 표현되어서 보기가 힘듬. 그냥 표가 길더라도 보여줄것. 어차피 상세페이지와 원문의 우측 패널의 영역은 별도의 스크롤 영역이라 무관함.
원문은 M01과 M02에 표현되고 있는데 일원화 할것. M02가 더 가독성이 좋음.
@@ -53,3 +53,11 @@ def test_route() -> None:
app.include_router(router_module.router)
got = TestClient(app).get("/api/m01/logic/source", params={"src": "산림품셈 3-1"}).json()
assert got["found"][0]["matched"] == "3-1"
def test_tables_split_and_br() -> None:
"""빈 줄 · 주석이 붙은 표 둘을 가름(49-2) · 칸 안 `<br>` = 줄바꿈."""
one = make.source("건설품셈 공통 6-1-1")["found"][0]
tables = [b["table"] for b in one["blocks"] if "table" in b]
assert [len(t[0]) for t in tables] == [7, 2]
assert tables[0][2][0] == "인력운반\n타설"
@@ -0,0 +1,158 @@
"""M02 구조물 정의 한 벌(`/api/m02/structure-definition` · PLAN 48-1).
못박는 것
- 판 1 = 옛 레지스트리 그대로 옮김(37종 · 옵션 235 · 키 그대로) · 검사 통과.
- 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자만(403).
- 저장마다 판 +1 · 앞 판은 `구조물정의_이력/판_N.json` · 낡은 판 409 · 틀린 정의 422(안 씀).
- 검사 = 키 겹침 · 꼴 · 규칙 대상 있음 · 그룹 형상.
- 저장한 정의를 `/structure-types` 가 바로 읽음 · 프로젝트 저장에 정의 판 · 없는 키 값 보존.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from B05_Profile import B05_Profile_Structures_Schema as schema
from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures
from B05_Profile.B05_Profile_Structures_Router import router as b05_router
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, load_structure_types
from common_util.common_util_auth import verify_session
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router
URL = "/api/m02/structure-definition"
TYPES = "/api/projects/structure-types"
ADMIN = {"role": "SYSTEM_ADMIN", "user_id": 1}
USER = {"role": "USER", "user_id": 2, "company_id": 1}
@pytest.fixture
def copy(tmp_path: Path, monkeypatch) -> Path:
"""정의 사본 — 마스터 정본은 안 씀."""
path = tmp_path / "구조물정의.json"
path.write_text(Path(schema.DEFINITION_PATH).read_text("utf-8"), "utf-8")
monkeypatch.setattr(schema, "DEFINITION_PATH", str(path))
return path
def _client(session: dict) -> TestClient:
app = FastAPI()
app.include_router(read_router)
app.include_router(b05_router)
app.dependency_overrides[verify_session] = lambda: session
return TestClient(app)
def test_판1_은_옛_레지스트리_그대로_검사_통과() -> None:
doc = definition.read()
assert doc["판"] == 1 and len(doc["types"]) == 37
assert sum(len(t["options"]) for t in doc["types"]) == 235
assert len(load_structure_types()) == 37
assert sum(len(t.options) for t in load_structure_types()) == 235
assert definition.check(doc) == []
assert [g["이름"] for g in doc["그룹"]["pipe"]] == ["관", "관 보호공(유입)", "관 보호공(유출)"]
assert {t["group"] for t in doc["types"]} <= set(doc["구조물군"])
def test_읽기는_누구나_쓰기는_시스템_관리자만(copy) -> None:
user = _client(USER)
got = user.get(URL)
assert got.status_code == 200 and got.json()["판"] == 1
assert user.put(URL, json={"판": 1, "정의": got.json()}).status_code == 403
assert json.loads(copy.read_text("utf-8"))["판"] == 1 # 안 씀
def test_저장마다_판_더하기_1_앞_판은_이력_structure_types_가_바로_읽음(copy) -> None:
admin = _client(ADMIN)
doc = admin.get(URL).json()
tid = doc["types"][0]["type_id"]
doc["types"][0]["name"] = "이름 바꿈"
res = admin.put(URL, json={"판": 1, "정의": doc})
assert res.status_code == 200, res.text
assert res.json()["판"] == 2
old = json.loads((copy.parent / "구조물정의_이력" / "판_1.json").read_text("utf-8"))
assert old["판"] == 1 and old["types"][0]["name"] != "이름 바꿈"
seen = {t["type_id"]: t for t in admin.get(TYPES).json()["types"]}
assert seen[tid]["name"] == "이름 바꿈" and len(seen) == 37
assert admin.put(URL, json={"판": 1, "정의": doc}).status_code == 409 # 낡은 판
assert admin.put(URL, json={"판": 2, "정의": res.json()}).json()["판"] == 3
assert (copy.parent / "구조물정의_이력" / "판_2.json").is_file()
def _rule(doc: dict) -> tuple[str, dict, dict]:
"""고르기 옵션 둘 이상 가진 종류 — (종류 키, 상위 옵션, 하위 옵션)."""
for t in doc["types"]:
picks = [o for o in t["options"] if o["input"] == "select"]
if len(picks) >= 2:
return t["type_id"], picks[0], picks[1]
raise AssertionError("고르기 둘인 종류 없음")
def test_검사_키_겹침_꼴_규칙_대상_형상(copy) -> None:
admin = _client(ADMIN)
base = admin.get(URL).json()
tid, up, down = _rule(base)
good = {
"종류": tid,
"만약": {"옵션": up["key"], "값들": [up["choices"][0]]},
"그러면": {"옵션": down["key"], "선택지": down["choices"][:1], "보이기": True},
}
assert definition.check({**base, "규칙": [good]}) == []
def bad(**change) -> dict:
return json.loads(json.dumps({**base, **change}))
dup = bad()
dup["types"].append(dup["types"][0]) # 종류 키 겹침
opt_dup = bad()
opt_dup["types"][0]["options"].append(opt_dup["types"][0]["options"][0]) # 옵션 키 겹침
shape = bad()
shape["types"][0]["options"][0]["input"] = "날짜" # 없는 꼴
cases = [
dup,
opt_dup,
shape,
bad(규칙=[{**good, "그러면": {**good["그러면"], "옵션": "없는키"}}]), # 대상 없음
bad(규칙=[{**good, "종류": "없는종류"}]),
bad(규칙=[{**good, "만약": {"옵션": up["key"], "값들": ["없는값"]}}]),
bad(규칙=[{**good, "그러면": {"옵션": down["key"], "선택지": ["없는값"]}}]),
bad(규칙=[{**good, "그러면": {"옵션": down["key"]}}]), # 효과 없음
bad(배치={tid: {up["key"]: {"줄": 0, "칸": 1}}}),
bad(배치={tid: {"없는키": {"줄": 1}}}),
bad(종류={tid: {"동작": ["없는동작"]}}),
bad(종류={"없는종류": {"기준측점": False}}),
bad(표시={tid: {"없는키": False}}),
bad(
그룹={"pipe": [{"id": "g1", "이름": "가", "옵션": [], "형상": {"폭": {"받기": True}}}]}
),
bad(모르는칸=1),
]
for i, doc in enumerate(cases):
res = admin.put(URL, json={"판": 1, "정의": doc})
assert res.status_code == 422, (i, res.text)
assert json.loads(copy.read_text("utf-8"))["판"] == 1 # 거절되면 안 씀
assert not (copy.parent / "구조물정의_이력").exists()
shaped = bad(종류={tid: {"기준측점": False}})
shaped["그룹"]["pipe"][0]["형상"] = {"길이": {"받기": True, "뜻": "관 길이"}}
assert admin.put(URL, json={"판": 1, "정의": shaped}).status_code == 200
def test_프로젝트_저장에_정의_판_없는_키_값은_남김(copy, tmp_path) -> None:
root = str(tmp_path / "project")
item = StructureInstance.model_validate(
{
"type_id": "mirror",
"placement": "point",
"chainage_m": 10.0,
"options": {"지난정의키": "옛값"},
}
)
assert save_structures(root, [item], base_revision=0) == 1
raw = json.loads(Path(root, "B05_Profile", "route", "structures.json").read_text("utf-8"))
assert raw["definition_version"] == 1
assert load_structures(root)[1][0].options["지난정의키"] == "옛값"
@@ -0,0 +1,60 @@
/* 48-1 — 구조물 정의 한 벌(resources/master_template/구조물정의.json)로 옮긴 뒤 화면 변화 0
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?root=master) · 시스템 관리자 · 저장 안 함
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 기대 수치:
* - def = { 판 ≥ 1, types: 37, options: 235 }(GET /api/m02/structure-definition)
* - types = { types: 37, options: 235, visibleAll: true, pipeGroups: ["관", "관 보호공(유입)", "관 보호공(유출)"] }
* (GET /api/projects/structure-types — 옛 꼴 그대로)
* - 좌측 「구조물 옵션 보이기」 → 종류 단추 kinds = 37(옵션 없는 종류도 · 옛 화면 그대로)
* · pipe 단추에 그룹 표지 「3」
*/
(async () => {
const W = (ms) => new Promise((r) => setTimeout(r, ms));
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
const until = async (fn, ms = 8000) => {
for (let t = 0; t < ms; t += 150) {
const v = fn();
if (v) return v;
await W(150);
}
return null;
};
const get = async (url) => {
const r = await fetch(url, { credentials: "include" });
return r.ok ? r.json() : { status: r.status };
};
const out = {};
const def = await get("/api/m02/structure-definition");
if (!def.types) return JSON.stringify({ error: "정의 못 읽음", def });
out.def = {
판: def.판,
types: def.types.length,
options: def.types.reduce((n, t) => n + t.options.length, 0),
};
const st = await get("/api/projects/structure-types");
const pipe = st.types.find((t) => t.type_id === "pipe");
out.types = {
types: st.types.length,
options: st.types.reduce((n, t) => n + t.options.length, 0),
visibleAll: st.types.every((t) => t.options.every((o) => typeof o.visible === "boolean")),
pipeGroups: pipe.groups.map((g) => g.name),
};
const row = await until(() => $("[data-options-row]"));
if (!row) return JSON.stringify({ ...out, error: "옵션 보이기 줄 없음" });
row.click();
await until(() => $$("[data-options-kind]").length);
const kinds = $$("[data-options-kind]");
out.screen = {
kinds: kinds.length,
withOptions: def.types.filter((t) => t.options.length).length,
pipeTag: $('[data-options-kind="pipe"]')?.innerText.replace(/\s+/g, " ").trim() ?? null,
};
out.ok =
out.def.types === 37 &&
out.def.options === 235 &&
out.types.types === 37 &&
out.types.options === 235 &&
out.types.pipeGroups.length === 3;
return JSON.stringify(out);
})();
@@ -0,0 +1,194 @@
/* 49 — 오른쪽 접는 패널 = 좌측 패널과 같은 손잡이 양식 · 패널 몸만 따로 스크롤 · M01 원문 표 칸 수 = M02
* 시작 화면: M01 마스터 데이터(#/m01-master-data) · 시스템 관리자 · 나무 item1(구조물 조합)이 M02 마스터 층에 있음
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) · 저장 안 함 · 끝에 열린 패널 되돌림
* 하는 일: M01 로직 GC000198 상세(흐름도 · 원문) → M02 일위대가 상세(원문 · 같은 GC000198 줄) → M02 면적산출 상세(일위대가 · 도면)
* 기대 수치(화면마다):
* - handleClass = 좌측 패널 손잡이와 같은 클래스(ui-workflow-overlay__handle ui-workflow-overlay__handle--side is-labeled)
* - 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 = 패널 위아래 끝이 창 안
* - ownScroll: 패널 몸을 굴리면 몸 scrollTop 만 바뀌고 본문 스크롤 그대로(mainMoved 0)
* - m01Cells = m02Cells(같은 로직 GC000198 원문 표 칸 수) · 표 둘(7칸 · 2칸)
* - m01Look = m02Look(sameLook true) — 같은 클래스 · 글자 크기(16px) · 표 칸 꼴 · tableFit 모두 true(표 높이 = 내용 · 자체 세로 스크롤 없음)
*/
(async () => {
const W = (ms) => new Promise((r) => setTimeout(r, ms));
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
const until = async (fn, ms = 10000) => {
for (let t = 0; t < ms; t += 150) {
const v = fn();
if (v) return v;
await W(150);
}
return null;
};
const shown = (n) => !!n && n.getBoundingClientRect().height > 0;
const LEFT = "ui-workflow-overlay__handle ui-workflow-overlay__handle--side is-labeled";
/** 한 패널 묶음 — 손잡이 꼴 · 하나만 열림 · 폭 공유 · 모두 접힘 · sticky · 몸 스크롤 */
const check = async (sp, scrollId) => {
const handles = $$(".ui-side-panels__handles > button", sp);
const ids = handles.map((h) => h.dataset.panel);
const was =
ids.find((id) => shown($(`.ui-side-panels__panel[data-panel="${id}"]`, sp))) ?? null;
const box = (id) => $(`.ui-side-panels__panel[data-panel="${id}"]`, sp);
const state = () => ({
open: ids.filter((id) => shown(box(id))),
expanded: handles.map((h) => h.getAttribute("aria-expanded")),
});
const out = {
handles: handles.length,
handleClass: handles.every((h) => h.className === LEFT),
labels: handles.map((h) => h.innerText.replace(/\s/g, "")),
steps: [],
};
for (const h of handles) {
if (h.getAttribute("aria-expanded") !== "true") h.click();
await W(300);
const b = box(h.dataset.panel);
out.steps.push({
...state(),
width: Math.round(b.getBoundingClientRect().width),
handleOutside: Math.round(b.getBoundingClientRect().left - h.getBoundingClientRect().right),
});
}
// sticky · 몸 스크롤 — 고른 패널을 펴고 봄
const pick = handles.find((h) => h.dataset.panel === scrollId);
if (pick.getAttribute("aria-expanded") !== "true") pick.click();
await W(400);
const panel = box(scrollId);
const body = $(".ui-side-panels__body", panel);
// 본문 스크롤 칸 — 위 칸 중 굴러가는 것 · 없으면 창 · 숨은 탭은 scroll 알림이 안 와 직접 보냄
let sc = 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;
}
const roll = async (y) => {
if (sc) sc.scrollTop = y;
else window.scrollTo(0, y);
(sc ?? window).dispatchEvent(new Event("scroll"));
await W(200);
};
const pos = () => (sc ? sc.scrollTop : window.scrollY);
await roll(0);
const t0 = panel.getBoundingClientRect().top;
await roll(250);
const t1 = panel.getBoundingClientRect().top;
await roll(400);
const r1 = panel.getBoundingClientRect();
const head = Math.round($(".app-header").getBoundingClientRect().bottom);
out.sticky = {
scroller: sc ? sc.className.split(" ")[0] : "window",
scrolled: Math.round(pos()),
tops: [t0, t1, r1.top].map(Math.round),
head,
};
out.inView = Math.round(r1.bottom) <= innerHeight && Math.round(r1.top) >= 0;
const y = pos();
body.scrollTop = 0;
body.scrollTop = 300;
body.dispatchEvent(new Event("scroll"));
await W(150);
out.ownScroll = {
bodyOverflow: body.scrollHeight > body.clientHeight,
bodyTop: Math.round(body.scrollTop),
mainMoved: Math.round(pos() - y),
};
body.scrollTop = 0;
await roll(0);
// 모두 접힘
$$(".ui-side-panels__handles > button[aria-expanded=true]", sp).forEach((h) => h.click());
await W(300);
out.none = { ...state(), rootW: Math.round(sp.getBoundingClientRect().width) };
// 되돌림
if (was) handles.find((h) => h.dataset.panel === was).click();
await W(300);
out.restored = (ids.find((id) => shown(box(id))) ?? null) === was;
return out;
};
const cells = (root) =>
$$(".m01lab-src__table", root).map((t) => [...t.rows].map((r) => r.cells.length).join(","));
/** 원문 꼴 — 클래스 · 글자 크기 · 표 칸 꼴 · 표 감싸개 높이 = 내용(자체 세로 스크롤 없음) */
const look = (root) => {
const src = $(".m01lab-src", root);
const fs = (sel) => (sel ? getComputedStyle(sel).fontSize : null);
const td = $(".m01lab-src__table td", src);
return {
classes: [...new Set($$("*", src).map((n) => n.className.toString()))].sort().join(" "),
font: [fs(src), fs($(".m01lab-src__p", src)), fs($(".m01lab-src__head", src)), fs(td)],
td: td ? ["padding", "whiteSpace", "borderBottom"].map((k) => getComputedStyle(td)[k]) : null,
tableFit: $$(".m01lab-src__table", src).map((t) => {
const box = t.parentElement;
return box.scrollHeight <= box.clientHeight;
}),
};
};
const result = {};
// ① M01 로직 상세 — 흐름도 · 원문
sessionStorage.setItem("m01.logic.pending", "GC000198");
const tab = $$(".ui-collapsible__title").find((h) => h.innerText.trim() === "단가산출 로직");
if (!tab) return JSON.stringify({ error: "M01 화면 아님" });
if (!tab.closest(".ui-collapsible").classList.contains("is-collapsed")) tab.click();
tab.click();
const m01 = await until(() => $(".m01lab-side .m02-dfp") && $(".m01lab-side > .ui-side-panels"));
if (!m01) return JSON.stringify({ error: "M01 로직 못 엶" });
await W(600);
result.m01 = await check(m01, "source");
const srcHandle = $('.ui-side-panels__handles > button[data-panel="source"]', m01);
const m01Was = srcHandle.getAttribute("aria-expanded");
if (m01Was !== "true") srcHandle.click();
await until(() => $(".m01lab-side .m01lab-src__table"));
result.m01Cells = cells($(".m01lab-side"));
result.m01Look = look($(".m01lab-side"));
if (m01Was !== "true") srcHandle.click();
// ② M02 일위대가 상세 — 원문(같은 로직 줄)
location.hash = "#/m02-master-template?open=구조물조합&root=master";
const node = await until(() => $('[data-node="i|item1"]'));
if (!node) return JSON.stringify({ ...result, error: "item1 없음" });
node.click();
const card = await until(() =>
$$(".m02-struct__card").find((c) => $("table.m02-labrow tbody tr[data-labor-logic]", c)),
);
$$(".m02-struct__card-tools button", card)
.find((b) => b.innerText.trim() === "수정")
?.click();
const row = await until(() =>
$(".ui-side-panels-row .m02-labor__edit")?.closest(".ui-side-panels-row"),
);
if (!row) return JSON.stringify({ ...result, error: "일위대가 상세 없음" });
const labor = $(":scope > .ui-side-panels", row);
const lh = $(".ui-side-panels__handles > button", labor);
const laborWas = lh.getAttribute("aria-expanded");
if (laborWas !== "true") lh.click();
const same = await until(() => $('tbody tr[data-labor-logic="GC000198"] .m02-labrow__name', row));
same?.click();
await until(() => $("[data-labor-source] .m01lab-src__table", row));
result.m02Cells = cells(row);
result.m02Look = look(row);
if (laborWas !== "true") lh.click();
await W(200);
result.labor = await check(labor, "source");
// ③ M02 면적산출 상세 — 일위대가 · 도면
node.click();
const vis = (b) => b && !b.hidden && b.offsetParent !== null;
const edits = () =>
$$(".m02-struct button").filter((b) => vis(b) && b.textContent.trim() === "수정");
await until(() => edits().length === 3);
(edits().find((b) => /면적/.test(b.title)) ?? edits()[2])?.click();
const area = await until(() =>
$$(".ui-side-panels").find((s) => shown(s) && $('[data-panel="drawing"]', s)),
);
if (!area) return JSON.stringify({ ...result, error: "면적산출 상세 없음" });
await W(600);
result.area = await check(area, "labor");
node.click();
await W(800);
result.sameCells = JSON.stringify(result.m01Cells) === JSON.stringify(result.m02Cells);
result.sameLook = JSON.stringify(result.m01Look) === JSON.stringify(result.m02Look);
return JSON.stringify(result);
})();
+33 -43
View File
@@ -1,4 +1,5 @@
/* 본문 오른쪽 접는 패널 여럿 — 접힌 패널 = 세로 글 띠 · 편 패널 = 공유 폭(--ui-side-panels-width) */
/* 본문 오른쪽 접는 패널 여럿 — 왼쪽 가장자리 손잡이(좌측 패널과 같은 꼴) · 편 패널 = 공유 폭(--ui-side-panels-width)
보이는 칸 안에 붙음(JS followView) — 몸만 따로 스크롤 · 메인 스크롤과 따로 */
/* 부르는 쪽 칸 — [본문, 패널 묶음] */
.ui-side-panels-row {
@@ -17,40 +18,45 @@
}
.ui-side-panels {
position: relative;
display: flex;
flex: none;
align-items: stretch;
align-self: flex-start;
/* 높이 · 붙는 자리 = JS(followView) · 처음 한 번 그릴 때까지 */
height: calc(100vh - 80px);
/* 손잡이 자리(24) + 틈 */
margin-left: calc(var(--spacing-24) + var(--spacing-8));
}
/* 모두 접힘 — 폭 0 · 손잡이만 왼쪽 밖에 남음 */
.ui-side-panels.is-collapsed {
width: 0;
}
/* 손잡이 줄 — 패널 왼쪽 밖 세로 가운데 · 패널 둘이면 위아래로 */
.ui-side-panels__handles {
position: absolute;
z-index: 2;
top: 50%;
left: calc(-1 * var(--spacing-24));
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-left: var(--spacing-8);
transform: translateY(-50%);
}
.ui-side-panels__handles > .ui-workflow-overlay__handle--side {
position: static;
transform: none;
border-right: 0;
border-left: 1px solid var(--panel-handle-border, var(--color-border));
border-radius: var(--radius-buttons) 0 0 var(--radius-buttons);
}
.ui-side-panels.is-resizing {
user-select: none;
}
.ui-side-panels__strip {
flex: none;
width: 32px;
padding: var(--spacing-8) 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-text-secondary);
font: inherit;
font-size: var(--text-body-sm);
font-weight: 600;
writing-mode: vertical-rl;
text-orientation: mixed;
text-align: start;
cursor: pointer;
}
.ui-side-panels__strip:hover,
.ui-side-panels__strip:focus-visible {
border-color: var(--color-primary);
color: var(--color-text);
}
.ui-side-panels__panel {
position: relative;
display: flex;
@@ -58,12 +64,12 @@
flex-direction: column;
width: var(--ui-side-panels-width, 420px);
min-width: 0;
height: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
}
.ui-side-panels__strip[hidden],
.ui-side-panels__panel[hidden] {
display: none;
}
@@ -101,22 +107,6 @@
color: var(--color-text);
}
.ui-side-panels__fold {
flex: none;
padding: 0 var(--spacing-4);
border: 0;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
font: inherit;
cursor: pointer;
}
.ui-side-panels__fold:hover,
.ui-side-panels__fold:focus-visible {
color: var(--color-primary);
}
.ui-side-panels__body {
flex: 1 1 auto;
min-width: 0;
+56 -28
View File
@@ -1,7 +1,9 @@
/* =============================================================================
* ui_template_side_panels.ts
* 본문 오른쪽 접는 패널 여럿 — 나란히 · 하나만 열림 · 모두 접힘 됨 · 폭 공유
* 접힌 패널 = 제목 세로 글 띠(누르면 폄) · 편 패널 = 머리(제목 · 접기 ▸) + 몸 · 왼쪽 경계 끌기(화살표 키)
* 좌측 패널과 같은 접기 양식 — 패널 왼쪽 가장자리 손잡이(공용 createWorkflowPanelHandle · 오른쪽으로 접힘)
* 패널마다 손잡이 하나(이름표 세로) · 누르면 폄/접음 · 모두 접히면 손잡이만 남음
* 편 패널 = 머리(제목) + 몸 · 왼쪽 경계 끌기(화살표 키) · 보이는 칸 안에 붙어 몸만 따로 스크롤(followView)
* 열린 패널 · 폭 = 부르는 쪽 취향 키(`aislo:pref:{prefKey}`)
* 부르기: const sp = createSidePanels({ prefKey, panels: [{ id, title, body }] }) →
* 본문과 함께 `.ui-side-panels-row` 칸에 [본문, sp.root] 로 넣음
@@ -10,11 +12,10 @@
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { el } from "./ui_template_elements";
import { currentLanguageIndex } from "./ui_template_locale";
import { createWorkflowPanelHandle } from "./ui_template_overlay";
import "./ui_template_side_panels.css";
const TEXT = {
Open: ["{v} 펴기", "Open {v}"],
Close: ["{v} 접기", "Collapse {v}"],
Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"],
} as const;
@@ -89,29 +90,15 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels {
if (keep) save();
};
const handles = el("div", { className: "ui-side-panels__handles" });
root.append(handles);
const views = opts.panels.map((p) => {
const bodyId = `ui-side-panel-${opts.prefKey}-${p.id}`;
const strip = el("button", {
className: "ui-side-panels__strip",
text: p.title,
attrs: { type: "button", "aria-expanded": "false", "aria-controls": bodyId },
});
strip.title = ts("Open", p.title);
strip.addEventListener("click", () => {
open(p.id);
fold.focus();
});
const fold = el("button", {
className: "ui-side-panels__fold",
text: "▸",
attrs: { type: "button", "aria-expanded": "true", "aria-controls": bodyId },
});
fold.title = ts("Close", p.title);
fold.setAttribute("aria-label", fold.title);
fold.addEventListener("click", () => {
open(null);
strip.focus();
});
const handle = createWorkflowPanelHandle("side", "right", p.title);
handle.root.setAttribute("aria-controls", bodyId);
handle.root.dataset.panel = p.id;
handle.root.addEventListener("click", () => open(now === p.id ? null : p.id));
handles.append(handle.root);
const grip = el("div", {
className: "ui-side-panels__grip",
attrs: {
@@ -128,7 +115,7 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels {
grip,
el("header", {
className: "ui-side-panels__head",
children: [el("strong", { className: "ui-side-panels__title", text: p.title }), fold],
children: [el("strong", { className: "ui-side-panels__title", text: p.title })],
}),
el("div", { className: "ui-side-panels__body", attrs: { id: bodyId }, children: [p.body] }),
],
@@ -156,16 +143,17 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels {
ev.preventDefault();
setWidth(width + d, true);
});
root.append(strip, box);
return { id: p.id, strip, box };
root.append(box);
return { id: p.id, handle, box };
});
const draw = (): void => {
for (const v of views) {
const on = v.id === now;
v.strip.hidden = on;
v.handle.setOpen(on);
v.box.hidden = !on;
}
root.classList.toggle("is-collapsed", now === null);
};
function open(id: string | null): void {
@@ -178,5 +166,45 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels {
}
draw();
followView(root);
return { root, open, current: () => now, width: () => width };
}
/** 가장 가까운 스크롤 칸(없으면 창) */
function scroller(node: HTMLElement): HTMLElement | null {
for (let n = node.parentElement; n; n = n.parentElement) {
const y = getComputedStyle(n).overflowY;
if ((y === "auto" || y === "scroll") && n.scrollHeight > n.clientHeight + 1) return n;
}
return null;
}
/**
* 패널 묶음을 보이는 칸 안에 붙임 — 높이 = 보이는 칸 · 본문을 굴려도 그 자리(몸만 따로 스크롤)
* CSS sticky 는 위 칸의 overflow 가 막아 JS 로 — 어느 칸이 굴러도(capture) 다시 잼
*/
function followView(node: HTMLElement): void {
const ref = new WeakRef(node); // 떼었다 다시 붙이는 화면(M01)도 있어 떼어져도 듣기는 남김 · 버려지면 그침
const place = (): void => {
const root = ref.deref();
if (!root) return stop();
if (!root.isConnected) return;
const box = scroller(root)?.getBoundingClientRect();
const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0;
const top = Math.max(head, box?.top ?? 0) + 8;
const bottom = Math.min(window.innerHeight, box?.bottom ?? window.innerHeight) - 8;
const row = root.parentElement?.getBoundingClientRect();
if (!row) return;
const height = Math.max(240, bottom - top);
root.style.height = `${height}px`;
const shift = Math.min(Math.max(0, top - row.top), Math.max(0, row.height - height));
root.style.transform = shift ? `translateY(${Math.round(shift)}px)` : "";
};
const stop = (): void => {
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
setTimeout(place); // 붙은 뒤 한 번
}