feat(가이드): 538-3 글 더블클릭 바로 고침 — 시스템 관리자만 · 판 409 · 원자 쓰기
- 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 원문 줄 textarea(높이 자동)
· 바깥 누름 · Ctrl+Enter 저장(안 바뀌면 요청 없음) · Esc 취소 · 한글 조합 중 Enter · Esc 무시
· 고침 열린 채 모달 닫으면 저장 · 저장 뒤 그 항목만 다시 그림
- PUT /api/help/{page}/{key} {version, start, end, text} — require_system_admin(403) · 판 다르면 409 ·
그 줄만 갈아 임시 파일 → os.replace · 줄바꿈(CRLF) · BOM 그대로 · 409 면 다시 불러오고 내 글은 절 머리에 보여 둠
- 모달 Esc: 몸이 막은 Esc · 한글 조합 중 Esc 는 안 닫음
- 시험: 줄 바꿈 · 빈 글 지움 · CRLF/BOM · 409 · 403 · 나쁜 범위/항목 · 원자 쓰기 실패 때 원본 그대로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,19 +5,24 @@
|
|||||||
`_` 로 시작하는 파일 · 그림은 항목 아님.
|
`_` 로 시작하는 파일 · 그림은 항목 아님.
|
||||||
판(version) = 파일 sha256 앞 16자 · 페이지 제목 = `00_설명서` 의 제목.
|
판(version) = 파일 sha256 앞 16자 · 페이지 제목 = `00_설명서` 의 제목.
|
||||||
그림 = 페이지 폴더의 `이름.webp|png|jpg` · 판 = 고친 시각 · 내려주기는 로그인만 · `no-cache`.
|
그림 = 페이지 폴더의 `이름.webp|png|jpg` · 판 = 고친 시각 · 내려주기는 로그인만 · `no-cache`.
|
||||||
|
바로 고침(538-3) = 시스템 관리자만 · 판이 같을 때만 그 줄 범위를 갈아 원자 쓰기 · 다르면 409.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import codecs
|
||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
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"])
|
router = APIRouter(prefix="/api/help", tags=["help"])
|
||||||
|
|
||||||
@@ -105,3 +110,47 @@ def get_image(page: str, name: str) -> FileResponse:
|
|||||||
with path.open("rb") as f: # 올린 그림은 이름과 달리 WebP — 머리 글자로 가름
|
with path.open("rb") as f: # 올린 그림은 이름과 달리 WebP — 머리 글자로 가름
|
||||||
media = MEDIA.get(f.read(4), "image/jpeg")
|
media = MEDIA.get(f.read(4), "image/jpeg")
|
||||||
return FileResponse(path, media_type=media, headers={"Cache-Control": "no-cache"})
|
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)}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ def test_html_은_먼저_막음(html) -> None:
|
|||||||
def root(monkeypatch):
|
def root(monkeypatch):
|
||||||
path = Path(tempfile.mkdtemp())
|
path = Path(tempfile.mkdtemp())
|
||||||
(path / "B05").mkdir()
|
(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)
|
monkeypatch.setattr(help_router, "HELP_ROOT", path)
|
||||||
yield path
|
yield path
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
@@ -130,3 +130,76 @@ def test_그림_내려주기_no_cache(root) -> None:
|
|||||||
def test_그림_404(root, url) -> None:
|
def test_그림_404(root, url) -> None:
|
||||||
(root / "B05" / "full.webp").write_bytes(b"RIFF0000WEBP")
|
(root / "B05" / "full.webp").write_bytes(b"RIFF0000WEBP")
|
||||||
assert client().get(url).status_code == 404
|
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"
|
||||||
|
] # 임시 파일 남지 않음
|
||||||
|
|||||||
@@ -185,3 +185,30 @@
|
|||||||
.ui-help__text ol {
|
.ui-help__text ol {
|
||||||
padding-left: 1.4em;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import "./ui_template_help.css";
|
import "./ui_template_help.css";
|
||||||
import { API_BASE_URL, PLAN_STEP_ROUTES, ROUTES, STAB_STEP_ROUTES } from "@config/config_frontend";
|
import { API_BASE_URL, PLAN_STEP_ROUTES, ROUTES, STAB_STEP_ROUTES } from "@config/config_frontend";
|
||||||
import { createButton, el } from "./ui_template_elements";
|
import { createButton, el } from "./ui_template_elements";
|
||||||
|
import { attachHelpEditing } from "./ui_template_help_edit";
|
||||||
import { openModal } from "./ui_template_modal";
|
import { openModal } from "./ui_template_modal";
|
||||||
|
|
||||||
export interface HelpItem {
|
export interface HelpItem {
|
||||||
@@ -306,6 +307,10 @@ async function openHelp(): Promise<void> {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
goTo(goto.dataset.helpGoto ?? "");
|
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] }));
|
body.append(el("div", { className: "ui-help__grid", children: [toc, article] }));
|
||||||
// 모달이 붙은 뒤 이동 — mount 는 붙기 전
|
// 모달이 붙은 뒤 이동 — mount 는 붙기 전
|
||||||
requestAnimationFrame(() => goTo(start));
|
requestAnimationFrame(() => goTo(start));
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* ui_template_help_edit.ts
|
||||||
|
* [?] 사용자 가이드 바로 고침(538-3) — 시스템 관리자만(서버도 시스템 관리자만 받음).
|
||||||
|
* 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 덩어리 원문 마크다운 textarea 로 바꿔 끼움.
|
||||||
|
* 바깥 누름 · Ctrl+Enter = 저장(안 바뀌었으면 요청 안 함) · Esc = 취소 · 한글 조합 중 Enter · Esc 무시 ·
|
||||||
|
* 고침이 열린 채 모달을 닫으면 저장(`flush`).
|
||||||
|
* 저장 = `PUT /api/help/{page}/{key}` {version, start, end, text} → 그 항목만 다시 그림 ·
|
||||||
|
* 409 = 「다른 관리자가 먼저 고침」 → 다시 불러옴 + 내 글은 그 절 머리에 보여 둠.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
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: "내가 쓴 글 — 저장 안 됨 · 복사해 다시 넣음",
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -55,7 +55,11 @@ export function openModal(opts: ModalOptions): { close: () => void } {
|
|||||||
backdrop.addEventListener("pointerdown", (ev) => (outside = ev.target === backdrop));
|
backdrop.addEventListener("pointerdown", (ev) => (outside = ev.target === backdrop));
|
||||||
backdrop.addEventListener("pointerup", (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("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);
|
opts.mount(body, close, actions);
|
||||||
// 단추가 있을 때만 머리에 — [닫기] 는 머리 직속 그대로(B05 추천의 `.ui-modal__top > button` 갈아 끼우기)
|
// 단추가 있을 때만 머리에 — [닫기] 는 머리 직속 그대로(B05 추천의 `.ui-modal__top > button` 갈아 끼우기)
|
||||||
if (actions.childElementCount) closeButton.before(actions);
|
if (actions.childElementCount) closeButton.before(actions);
|
||||||
|
|||||||
Reference in New Issue
Block a user