Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1' and 'origin/sub_laptop_2' into sub_laptop_7

This commit is contained in:
2026-10-26 09:05:48 +09:00
8 changed files with 501 additions and 11 deletions
+30 -6
View File
@@ -33,6 +33,8 @@
단계 = 아래 열쇠 중 하나 + 덧붙임
{"click": "<css>"} 누름 · {"click": …, "confirm": true} = 확인창만 여는 위험 단추(아래)
{"fill": "<css>", "value": "글"} 칸 채움
{"select": "<css>", "value": "값"} 고르기 칸(<select>) 값 고름(option value 또는 글)
{"scroll": "<css>"} 그 요소가 화면에 들어오게 스크롤
{"hover": "<css>"} 마우스 올림(툴팁)
{"contextmenu": "<css>"} 오른쪽 누름(누름 메뉴 열기)
{"press": "Escape"} 키 · {"press": "Enter", "sel": "<css>"} = 그 요소에서
@@ -49,6 +51,9 @@
- 확인창을 찍을 때만 `"confirm": true` 로 그 단추를 누름 — 그 장면은 after 에 누름 단계가 있어야 함([취소])
- 페이지의 /api 쓰기 요청(PUT · PATCH · DELETE · allow_post 밖 POST)은 모두 끊음 · 끊은 요청은 표에 적음
- 브라우저 확인창(window.confirm)은 늘 취소 · 새 컨텍스트라 사용자 브라우저 · 저장소와 안 섞임
- 장면마다 새 탭 · sessionStorage 비우고 시작(앞 장면 조작이 안 남음) · localStorage 는 프로젝트 · 테마 ·
말만 세움
- clip · callouts 대상은 찍기 전 화면 안으로 스크롤(clip 먼저 · 번호 차례) 뒤 자리를 잼
- `_manual.json`(관리자가 손으로 바꾼 그림 · 이름 목록 또는 이름 열쇠 사전) 이름은 건너뜀(--force 면 찍음)
3D(WebGL) · 지도(카카오) · CAD(iframe)
@@ -80,7 +85,9 @@ STEP_TIMEOUT = 15000
SETTLE_MS = 500
CLIP_PAD = 16
PROJECT_KEYS = ("road", "stab", "plan", "none")
STEP_KINDS = ("click", "contextmenu", "fill", "hover", "press", "eval", "wait", "wait_ms")
STEP_KINDS = (
"click", "contextmenu", "fill", "select", "hover", "press", "eval", "wait", "wait_ms", "scroll",
) # fmt: skip
SCENE_KEYS = {
"name", "route", "project", "viewport", "before", "settle_ms", "clip", "clip_pad",
"callouts", "after", "allow_post",
@@ -96,6 +103,7 @@ DANGER_RE = re.compile(
SAFE_RE = re.compile(r"취소|닫기|\b(cancel|close)", re.I)
THEME_KEY, LANG_KEY, PROJECT_KEY = "frd_theme", "frd_lang", "frd_current_project_id"
CALLOUT_CLASS = "__guide_callout"
SEEDED = "__guide_seeded" # 탭 이름(window.name)에 남겨 첫 적재를 가림
YELLOW = "#FFD400"
RING_PAD, DOT = 4, 28 # 테두리 바깥 여백 · 번호 원 지름(CSS px)
@@ -142,8 +150,8 @@ def _check_steps(steps: Any, where: str) -> tuple[list[str], bool]:
if not isinstance(val, str) or not val.strip():
errs.append(f"{at} {kind} 값이 빈 글")
continue
if kind == "fill" and not isinstance(st.get("value"), str):
errs.append(f"{at} fill 은 value(글) 필요")
if kind in ("fill", "select") and not isinstance(st.get("value"), str):
errs.append(f"{at} {kind} 은 value(글) 필요")
if st.get("confirm"):
if kind != "click":
errs.append(f"{at} confirm 은 click 에만")
@@ -356,6 +364,8 @@ def run_steps(page: Any, steps: list[dict[str, Any]]) -> None:
page.wait_for_timeout(st["wait_ms"])
elif "wait" in st:
_locate(page, st["wait"], frame).wait_for(state="visible", timeout=timeout)
elif "scroll" in st:
_locate(page, st["scroll"], frame).scroll_into_view_if_needed(timeout=timeout)
elif "eval" in st:
target = page.frame_locator(frame).locator(":root") if frame else page
target.evaluate(f"() => ({st['eval']})")
@@ -365,7 +375,7 @@ def run_steps(page: Any, steps: list[dict[str, Any]]) -> None:
else:
page.keyboard.press(st["press"])
else:
kind = next(k for k in ("click", "contextmenu", "fill", "hover") if k in st)
kind = next(k for k in ("click", "contextmenu", "fill", "select", "hover") if k in st)
loc = _locate(page, st[kind], frame)
loc.wait_for(state="visible", timeout=timeout)
if kind == "click":
@@ -377,6 +387,8 @@ def run_steps(page: Any, steps: list[dict[str, Any]]) -> None:
loc.click(button="right", timeout=timeout)
elif kind == "fill":
loc.fill(st["value"], timeout=timeout)
elif kind == "select":
loc.select_option(st["value"], timeout=timeout)
else:
loc.hover(timeout=timeout)
@@ -396,7 +408,7 @@ def _box(page: Any, sel: str, frame: str | None, what: str) -> dict[str, float]:
def shoot_scene(
context: Any, base: str, scene: dict[str, Any], pid: str | None
) -> tuple[bytes, list[str]]:
"""장면 하나 → (PNG 2배, 끊은 쓰기 요청). 새 탭마다 sessionStorage 새로."""
"""장면 하나 → (PNG 2배, 끊은 쓰기 요청). 새 탭 · 첫 적재 때 sessionStorage 비움."""
vw, vh = scene.get("viewport") or VIEWPORT
allow = scene.get("allow_post") or []
blocked: list[str] = []
@@ -406,7 +418,10 @@ def shoot_scene(
seed = json.dumps([THEME_KEY, LANG_KEY, PROJECT_KEY, pid])
page.add_init_script(
f"(([t, l, k, pid]) => {{ localStorage.setItem(t, 'light'); localStorage.setItem(l, 'ko');"
f" pid ? localStorage.setItem(k, pid) : localStorage.removeItem(k); }})({seed});"
f" pid ? localStorage.setItem(k, pid) : localStorage.removeItem(k);"
# 첫 적재만 비움 — 장면 안 새로고침 · 이동은 그 장면 조작을 지킴
f" if (!window.name.includes('{SEEDED}')) {{ sessionStorage.clear();"
f" window.name += '{SEEDED}'; }} }})({seed});"
)
page.on("dialog", lambda d: d.dismiss())
@@ -431,6 +446,15 @@ def shoot_scene(
run_steps(page, scene.get("before") or [])
page.wait_for_timeout(scene.get("settle_ms", SETTLE_MS))
# 찍을 대상을 화면 안으로 — clip 먼저 · 번호 차례(어긋난 번호 · 잘린 그림 막음)
targets = [(c["sel"], c.get("frame")) for c in scene.get("callouts") or []]
if scene.get("clip"):
targets.insert(0, (scene["clip"], None))
for sel, frame in targets:
try:
_locate(page, sel, frame).scroll_into_view_if_needed(timeout=5000)
except Exception:
pass # 안 보이면 아래 _box 가 까닭을 냄
items, boxes = [], []
for c in scene.get("callouts") or []:
ring, dot = callout_boxes(_box(page, c["sel"], c.get("frame"), f"번호 {c['n']}"),
+109 -2
View File
@@ -5,19 +5,30 @@
`_` 로 시작하는 파일 · 그림은 항목 아님.
판(version) = 파일 sha256 앞 16자 · 페이지 제목 = `00_설명서` 의 제목.
그림 = 페이지 폴더의 `이름.webp|png|jpg` · 판 = 고친 시각 · 내려주기는 로그인만 · `no-cache`.
바로 고침(538-3) = 시스템 관리자만 · 판이 같을 때만 그 줄 범위를 갈아 원자 쓰기 · 다르면 409.
그림 바꾸기(538-4) = 시스템 관리자만 · png · jpg · webp · 5 MB · Pillow 확인 · 폭 1800 px 로 줄임 →
WebP 로 그 이름에 덮음 · `_manual.json`(손으로 바꾼 그림 이름 — 자동 캡처가 안 덮음)에 더함.
"""
from __future__ import annotations
import codecs
import hashlib
import io
import json
import re
import threading
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse
from PIL import Image, ImageOps
from pydantic import BaseModel, Field
from common_util.common_util_auth import verify_session
from common_util.common_util_atomic import atomic_write_bytes
from common_util.common_util_auth import require_system_admin, verify_session
router = APIRouter(prefix="/api/help", tags=["help"])
@@ -105,3 +116,99 @@ def get_image(page: str, name: str) -> FileResponse:
with path.open("rb") as f: # 올린 그림은 이름과 달리 WebP — 머리 글자로 가름
media = MEDIA.get(f.read(4), "image/jpeg")
return FileResponse(path, media_type=media, headers={"Cache-Control": "no-cache"})
class HelpEdit(BaseModel):
version: str = Field(max_length=64)
start: int = Field(ge=0)
end: int = Field(ge=0)
text: str = Field(max_length=200_000)
# ponytail: 서버 한 벌 안 잠금 — 판 확인 ~ 쓰기 사이 경합만 막음 · 서버 여러 벌이면 파일 잠금
_write_lock = threading.Lock()
def item_path(page: str, key: str, root: Path = HELP_ROOT) -> Path:
folder = page_folder(page, root)
bad = not key or key.startswith((".", "_")) or any(c in key for c in "/\\")
path = folder / f"{key}.md"
if bad or not path.is_file():
raise HTTPException(status_code=404, detail="없는 항목")
return path
def replace_lines(page: str, key: str, edit: HelpEdit, root: Path = HELP_ROOT) -> dict[str, str]:
"""판이 같을 때만 start~end 줄(0부터 · 끝 포함)을 글로 갈아 끼움 — 빈 글 = 그 줄 지움."""
path = item_path(page, key, root)
with _write_lock:
blob = path.read_bytes()
if file_version(blob) != edit.version:
raise HTTPException(status_code=409, detail="다른 관리자가 먼저 고침")
text = blob.decode("utf-8-sig")
newline = "\r\n" if "\r\n" in text else "\n"
lines = text.split(newline)
if edit.start > edit.end or edit.end >= len(lines):
raise HTTPException(status_code=400, detail="줄 범위 틀림")
new = edit.text.replace("\r\n", "\n").split("\n") if edit.text else []
lines[edit.start : edit.end + 1] = new
out = newline.join(lines).encode("utf-8")
atomic_write_bytes(path, codecs.BOM_UTF8 + out if blob.startswith(codecs.BOM_UTF8) else out)
return next(item for item in read_help(page, root) if item["key"] == key)
@router.put("/{page}/{key}", dependencies=[Depends(require_system_admin)])
def put_item(page: str, key: str, edit: HelpEdit) -> dict[str, dict[str, str]]:
return {"item": replace_lines(page, key, edit, HELP_ROOT)}
MAX_IMG_BYTES = 5 * 1024 * 1024
MAX_IMG_WIDTH = 1800
UPLOAD_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
def to_webp(blob: bytes) -> bytes:
if not blob or len(blob) > MAX_IMG_BYTES:
raise HTTPException(status_code=400, detail="그림은 5 MB 이하")
try:
with Image.open(io.BytesIO(blob)) as raw:
if raw.format not in {"PNG", "JPEG", "MPO", "WEBP"}: # MPO = 사진기 JPEG
raise HTTPException(status_code=400, detail="png · jpg · webp 그림만")
img = ImageOps.exif_transpose(raw)
if img.width > MAX_IMG_WIDTH:
img = img.resize((MAX_IMG_WIDTH, round(img.height * MAX_IMG_WIDTH / img.width)))
if img.mode not in ("RGB", "RGBA"):
img = img.convert(
"RGBA" if "transparency" in img.info or "A" in img.mode else "RGB"
)
out = io.BytesIO()
img.save(out, "WEBP", quality=90)
except (OSError, ValueError, Image.DecompressionBombError) as error:
raise HTTPException(status_code=400, detail="그림 파일 아님") from error
return out.getvalue()
def save_image(page: str, name: str, blob: bytes, root: Path = HELP_ROOT) -> str:
path = image_path(page, name, root)
if not path.parent.is_dir():
raise HTTPException(status_code=404, detail="없는 페이지")
webp = to_webp(blob)
manual = path.parent / "_manual.json"
with _write_lock:
atomic_write_bytes(path, webp)
names = set(json.loads(manual.read_text(encoding="utf-8"))) if manual.is_file() else set()
names.add(name)
text = json.dumps(sorted(names), ensure_ascii=False, indent=2) + "\n"
atomic_write_bytes(manual, text.encode("utf-8"))
return image_version(path)
@router.post("/{page}/img", dependencies=[Depends(require_system_admin)])
async def post_image(
page: str, name: str = Form(...), file: UploadFile = File(...)
) -> dict[str, str]:
if Path(file.filename or "").suffix.lower() not in UPLOAD_SUFFIXES:
raise HTTPException(status_code=400, detail="png · jpg · webp 그림만")
blob = await file.read(MAX_IMG_BYTES + 1)
version = await run_in_threadpool(save_image, page, name, blob, HELP_ROOT)
return {"name": name, "version": version}
+142 -1
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
import io
import json
import os
import shutil
@@ -88,7 +89,7 @@ def test_html_은_먼저_막음(html) -> None:
def root(monkeypatch):
path = Path(tempfile.mkdtemp())
(path / "B05").mkdir()
(path / "B05" / "00_설명서.md").write_text("# 종단\n\n첫 문단\n", encoding="utf-8")
(path / "B05" / "00_설명서.md").write_bytes("# 종단\n\n첫 문단\n".encode())
monkeypatch.setattr(help_router, "HELP_ROOT", path)
yield path
shutil.rmtree(path, ignore_errors=True)
@@ -130,3 +131,143 @@ def test_그림_내려주기_no_cache(root) -> None:
def test_그림_404(root, url) -> None:
(root / "B05" / "full.webp").write_bytes(b"RIFF0000WEBP")
assert client().get(url).status_code == 404
# ── 538-3 글 바로 고침 ────────────────────────────────────────────────────────
def put(c: TestClient, version: str, start: int, end: int, text: str, key: str = "00_설명서"):
body = {"version": version, "start": start, "end": end, "text": text}
return c.put(f"/api/help/B05/{key}", json=body)
def version_of(root: Path) -> str:
return help_router.file_version((root / "B05" / "00_설명서.md").read_bytes())
def test_그_줄만_갈고_새_판(root) -> None:
old = version_of(root)
res = put(client(), old, 2, 2, "고친 문단\n둘째 줄")
assert res.status_code == 200
item = res.json()["item"]
text = (root / "B05" / "00_설명서.md").read_bytes().decode()
assert text == "# 종단\n\n고친 문단\n둘째 줄\n" == item["text"]
assert item["version"] == version_of(root) != old
assert put(client(), item["version"], 2, 3, "").status_code == 200 # 빈 글 = 그 줄 지움
assert (root / "B05" / "00_설명서.md").read_bytes().decode() == "# 종단\n\n"
def test_crlf_bom_그대로(root) -> None:
path = root / "B05" / "00_설명서.md"
path.write_bytes(b"\xef\xbb\xbf# A\r\n\r\nB\r\n")
assert put(client(), version_of(root), 2, 2, "C").status_code == 200
assert path.read_bytes() == b"\xef\xbb\xbf# A\r\n\r\nC\r\n"
def test_판_다르면_409_파일_그대로(root) -> None:
before = (root / "B05" / "00_설명서.md").read_bytes()
assert put(client(), "0" * 16, 2, 2, "남의 글").status_code == 409
assert (root / "B05" / "00_설명서.md").read_bytes() == before
def test_시스템_관리자만_403(root) -> None:
before = (root / "B05" / "00_설명서.md").read_bytes()
for role in ["USER", "COMPANY_ADMIN"]:
assert put(client(role), version_of(root), 2, 2, "x").status_code == 403
assert (root / "B05" / "00_설명서.md").read_bytes() == before
@pytest.mark.parametrize(
("start", "end", "key", "code"),
[
(3, 2, "00_설명서", 400),
(0, 9, "00_설명서", 400),
(0, 0, "없음", 404),
(0, 0, "_manual", 404),
(0, 0, "..%2F..%2FCLAUDE", 404),
],
)
def test_나쁜_범위_항목(root, start, end, key, code) -> None:
assert put(client(), version_of(root), start, end, "x", key).status_code == code
def test_원자_쓰기_실패해도_원본_그대로(root, monkeypatch) -> None:
before = (root / "B05" / "00_설명서.md").read_bytes()
def boom(*_args):
raise OSError("쓰기 실패")
monkeypatch.setattr("common_util.common_util_atomic.os.replace", boom)
with pytest.raises(OSError):
put(client(), version_of(root), 2, 2, "반쯤")
assert (root / "B05" / "00_설명서.md").read_bytes() == before
assert sorted(p.name for p in (root / "B05").iterdir()) == [
"00_설명서.md"
] # 임시 파일 남지 않음
# ── 538-4 그림 바꾸기 ─────────────────────────────────────────────────────────
def png(width: int = 40, height: int = 20) -> bytes:
from PIL import Image
out = io.BytesIO()
Image.new("RGB", (width, height), (200, 30, 30)).save(out, "PNG")
return out.getvalue()
def upload(c: TestClient, name: str, blob: bytes, filename: str = "고른.png", page: str = "B05"):
return c.post(
f"/api/help/{page}/img",
data={"name": name},
files={"file": (filename, blob, "application/octet-stream")},
)
def test_그림_webp_로_덮고_manual_에_적음(root) -> None:
from PIL import Image
(root / "B05" / "full.webp").write_bytes(b"old")
res = upload(client(), "full.webp", png(3000, 1500))
assert res.status_code == 200
body = res.json()
with Image.open(root / "B05" / "full.webp") as img:
assert (img.format, img.size) == ("WEBP", (1800, 900)) # 폭 1800 넘으면 줄임
assert body == {
"name": "full.webp",
"version": help_router.image_version(root / "B05" / "full.webp"),
}
assert upload(client(), "new_one.webp", png(), "a.jpg").status_code == 200 # 새 이름도 받음
manual = json.loads((root / "B05" / "_manual.json").read_text(encoding="utf-8"))
assert manual == ["full.webp", "new_one.webp"]
assert [i["key"] for i in client().get("/api/help").json()["pages"][0]["items"]] == [
"00_설명서"
]
@pytest.mark.parametrize(
("name", "blob", "filename", "code"),
[
("full.webp", png(), "고른.gif", 400), # 확장자
("full.webp", b"not an image at all", "고른.png", 400), # 그림 아님
("full.webp", b"", "고른.png", 400),
("../full.webp", png(), "고른.png", 404), # 경로 탈출
("full.gif", png(), "고른.png", 404),
("한글.webp", png(), "고른.png", 404),
],
)
def test_그림_거절(root, name, blob, filename, code) -> None:
assert upload(client(), name, blob, filename).status_code == code
assert sorted(p.name for p in (root / "B05").iterdir()) == ["00_설명서.md"]
def test_그림_5MB_넘으면_거절(root, monkeypatch) -> None:
monkeypatch.setattr(help_router, "MAX_IMG_BYTES", 1000)
assert upload(client(), "full.webp", png(400, 400) + b"\0" * 2000).status_code == 400
def test_그림_시스템_관리자만_403(root) -> None:
assert upload(client("USER"), "full.webp", png()).status_code == 403
assert upload(client(), "full.webp", png(), page="..%2FB05").status_code == 404
assert not (root / "B05" / "full.webp").exists()
+10 -1
View File
@@ -28,7 +28,14 @@ def _folder(scenes, manual=None) -> Path:
def test_good_scene_passes():
sc = _scene(
before=[{"click": ".card"}, {"wait": ".modal"}, {"wait_ms": 300}, {"hover": ".tip"}],
before=[
{"click": ".card"},
{"wait": ".modal"},
{"wait_ms": 300},
{"hover": ".tip"},
{"select": "#kind", "value": "plan"},
{"scroll": ".far"},
],
clip=".modal",
callouts=[{"n": 1, "sel": ".a"}, {"n": 2, "sel": ".b", "pos": "br"}],
after=[{"press": "Escape"}],
@@ -57,6 +64,8 @@ def test_name_rule(name):
{"callouts": [{"n": 1, "sel": ".a", "pos": "mid"}]},
{"before": [{"click": ".a", "wait": ".b"}]},
{"before": [{"fill": ".a"}]},
{"before": [{"select": ".a"}]},
{"before": [{"scroll": ""}]},
{"before": [{"jump": ".a"}]},
{"allow_post": ["projects"]},
{"extra": 1},
+27
View File
@@ -185,3 +185,30 @@
.ui-help__text ol {
padding-left: 1.4em;
}
/* 바로 고침(538-3) — 시스템 관리자만 · 덩어리 둘레 점선 · 원문 글 상자 */
.ui-help__text.is-editable [data-src]:hover {
outline: 1px dashed var(--color-border);
outline-offset: 2px;
}
.ui-help__edit {
display: block;
box-sizing: border-box;
width: 100%;
margin: 0 0 var(--spacing-8);
padding: var(--spacing-8);
border: 1px solid var(--color-accent);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
line-height: 1.6;
resize: vertical;
}
.ui-help__mine {
margin: 0 0 var(--spacing-8);
color: var(--color-warning);
font-size: 0.9em;
}
+5
View File
@@ -14,6 +14,7 @@
import "./ui_template_help.css";
import { API_BASE_URL, PLAN_STEP_ROUTES, ROUTES, STAB_STEP_ROUTES } from "@config/config_frontend";
import { createButton, el } from "./ui_template_elements";
import { attachHelpEditing } from "./ui_template_help_edit";
import { openModal } from "./ui_template_modal";
export interface HelpItem {
@@ -306,6 +307,10 @@ async function openHelp(): Promise<void> {
ev.preventDefault();
goTo(goto.dataset.helpGoto ?? "");
});
if (guide.admin)
flush = attachHelpEditing(article, pages, (page, item) =>
renderHelpItem(page, item, true),
).flush;
body.append(el("div", { className: "ui-help__grid", children: [toc, article] }));
// 모달이 붙은 뒤 이동 — mount 는 붙기 전
requestAnimationFrame(() => goTo(start));
+173
View File
@@ -0,0 +1,173 @@
/* =============================================================================
* ui_template_help_edit.ts
* [?] 사용자 가이드 바로 고침(538-3) — 시스템 관리자만(서버도 시스템 관리자만 받음).
* 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 덩어리 원문 마크다운 textarea 로 바꿔 끼움.
* 바깥 누름 · Ctrl+Enter = 저장(안 바뀌었으면 요청 안 함) · Esc = 취소 · 한글 조합 중 Enter · Esc 무시 ·
* 고침이 열린 채 모달을 닫으면 저장(`flush`).
* 저장 = `PUT /api/help/{page}/{key}` {version, start, end, text} → 그 항목만 다시 그림 ·
* 409 = 「다른 관리자가 먼저 고침」 → 다시 불러옴 + 내 글은 그 절 머리에 보여 둠.
* 그림 바꾸기(538-4) = 그림(또는 빈 그림 자리) 더블클릭 → 파일 고르기 → `POST /api/help/{page}/img`
* (그 그림 이름에 WebP 로 덮음) → 새 판으로 그 항목만 다시 그림. 그림 한 번 누름 = 아무 일 없음.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { el, showToast } from "./ui_template_elements";
import type { HelpGuide, HelpItem, HelpPage } from "./ui_template_help";
const TEXT = {
conflict: "다른 관리자가 먼저 고침 — 다시 불러옴",
failed: "저장 못 함 — 다시 불러옴",
mine: "내가 쓴 글 — 저장 안 됨 · 복사해 다시 넣음",
imgFailed: "그림 못 바꿈",
};
type Render = (page: HelpPage, item: HelpItem) => string;
export function attachHelpEditing(
article: HTMLElement,
pages: HelpPage[],
render: Render,
): { flush: () => Promise<boolean> } {
let open: (() => Promise<void>) | null = null;
let pending: Promise<void> = Promise.resolve();
article.classList.add("is-editable");
const find = (section: HTMLElement): { page: HelpPage; item: HelpItem } | null => {
const page = pages.find((p) => p.page === section.dataset.page);
const item = page?.items.find((i) => i.key === section.dataset.key);
return page && item ? { page, item } : null;
};
/** 서버의 지금 글로 그 항목 · 그림 판을 바꿔 둠 */
const reload = async (page: HelpPage, item: HelpItem): Promise<void> => {
const response = await fetch(`${API_BASE_URL}/help`, {
credentials: "include",
cache: "no-store",
});
if (!response.ok) return;
const fresh = ((await response.json()) as HelpGuide).pages.find((p) => p.page === page.page);
const freshItem = fresh?.items.find((i) => i.key === item.key);
if (fresh) page.images = fresh.images;
if (freshItem) Object.assign(item, freshItem);
};
const keepMine = (section: HTMLElement, text: string): void => {
const area = el("textarea", { className: "ui-help__edit", attrs: { readonly: "" } });
area.value = text;
section.prepend(el("div", { className: "ui-help__mine", children: [TEXT.mine, area] }));
};
const edit = (section: HTMLElement, block: HTMLElement): void => {
const found = find(section);
if (!found) return;
const { page, item } = found;
const [start, end] = (block.dataset.src ?? "").split("-").map(Number);
const before = item.text
.split(/\r?\n/)
.slice(start, end + 1)
.join("\n");
const area = el("textarea", { className: "ui-help__edit" });
area.value = before;
const fit = (): void => {
area.style.height = "auto";
area.style.height = `${area.scrollHeight + 2}px`;
};
let done = false;
const finish = async (keep: boolean): Promise<void> => {
if (done) return;
done = true;
open = null;
const text = area.value;
if (!keep || text === before) return void area.replaceWith(block);
area.disabled = true;
const response = await fetch(
`${API_BASE_URL}/help/${page.page}/${encodeURIComponent(item.key)}`,
{
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ version: item.version, start, end, text }),
},
).catch(() => null);
if (response?.ok) {
Object.assign(item, ((await response.json()) as { item: HelpItem }).item);
section.innerHTML = render(page, item);
return;
}
showToast(response?.status === 409 ? TEXT.conflict : TEXT.failed, "warning");
await reload(page, item).catch(() => undefined);
section.innerHTML = render(page, item);
keepMine(section, text);
};
area.addEventListener("input", fit);
area.addEventListener("keydown", (ev) => {
if (ev.isComposing || ev.keyCode === 229) return;
if (ev.key === "Escape") {
ev.preventDefault(); // 모달 Esc 닫기도 막음
ev.stopPropagation();
void finish(false);
} else if (ev.key === "Enter" && (ev.ctrlKey || ev.metaKey)) {
ev.preventDefault();
pending = finish(true);
}
});
area.addEventListener("blur", () => (pending = finish(true)));
open = () => (pending = finish(true));
block.replaceWith(area);
fit();
area.focus();
};
// 그림 고르기 — 하나를 돌려 씀 · 고른 뒤 값 비움(같은 파일 다시 골라도 change)
const picker = el("input", {
attrs: { type: "file", accept: "image/png,image/jpeg,image/webp" },
});
let pickFor: { section: HTMLElement; name: string } | null = null;
picker.addEventListener("change", async () => {
const file = picker.files?.[0];
picker.value = "";
const found = pickFor && find(pickFor.section);
if (!file || !pickFor || !found) return;
const { section, name } = pickFor;
const form = new FormData();
form.append("name", name);
form.append("file", file);
const response = await fetch(`${API_BASE_URL}/help/${found.page.page}/img`, {
method: "POST",
credentials: "include",
body: form,
}).catch(() => null);
const result = (await response?.json().catch(() => null)) as {
version?: string;
detail?: string;
} | null;
if (!response?.ok || !result?.version)
return void showToast(result?.detail ?? TEXT.imgFailed, "error");
found.page.images = { ...found.page.images, [name]: result.version };
section.innerHTML = render(found.page, found.item);
});
article.addEventListener("dblclick", (ev) => {
const target = ev.target as HTMLElement;
const section = target.closest<HTMLElement>(".ui-help__item");
if (!section || open || target.closest("textarea, .ui-help__mine")) return;
const figure = target.closest<HTMLElement>("figure[data-img]");
if (figure && !target.closest("figcaption")) {
pickFor = { section, name: figure.dataset.img ?? "" };
return void picker.click();
}
const block = target.closest<HTMLElement>("[data-src]");
if (!block || !section.contains(block)) return;
ev.preventDefault();
window.getSelection()?.removeAllRanges();
edit(section, block);
});
return {
flush: async () => {
if (open) await open();
await pending;
return true;
},
};
}
+5 -1
View File
@@ -55,7 +55,11 @@ export function openModal(opts: ModalOptions): { close: () => void } {
backdrop.addEventListener("pointerdown", (ev) => (outside = ev.target === backdrop));
backdrop.addEventListener("pointerup", (ev) => (outside &&= ev.target === backdrop));
backdrop.addEventListener("click", (ev) => ev.target === backdrop && outside && void ask());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && void ask());
// 몸이 막은 Esc(글 고침 취소) · 한글 조합 중 Esc 는 안 닫음
backdrop.addEventListener(
"keydown",
(ev) => ev.key === "Escape" && !ev.defaultPrevented && !ev.isComposing && void ask(),
);
opts.mount(body, close, actions);
// 단추가 있을 때만 머리에 — [닫기] 는 머리 직속 그대로(B05 추천의 `.ui-modal__top > button` 갈아 끼우기)
if (actions.childElementCount) closeButton.before(actions);