// 원본 공사설명서·시방서 편집기(TFRMESTEDITOR) 서식 — 문단모양(mnuFormatPar: 정렬·들여쓰기·줄간격), 표만들기(mnuTableCreate),
// 표모양(mnuFormatTable: 테두리·칸 여백·너비), 책갈피(popupBookMark: 이름 붙이기·찾아가기),
// 양식목록(mnuFormatTableS·popupListTableS: 서버 양식 보관함 종류 document 에서 불러오기·지우기),
// 기본양식으로 저장(mnuSaveToDefault·popupSavetoDefault: 이름 「기본양식-설명서」·「기본양식-시방서」 — 빈 문서를 열 때 채움).
// 본문은 docs[kind].html(허용한 태그·속성만 남김)과 글자만 뽑은 docs[kind].text 를 함께 둔다.
const EstDocEditor = (() => {
const TAGS = new Set([
"P",
"DIV",
"BR",
"B",
"STRONG",
"I",
"EM",
"U",
"TABLE",
"TBODY",
"THEAD",
"TR",
"TD",
"TH",
"A",
"SPAN",
"H1",
"H2",
"H3",
"UL",
"OL",
"LI",
]);
const STYLE =
/^(text-align|text-indent|line-height|margin-left|border|border-collapse|padding|width|font-weight|font-style|text-decoration)$/;
const DEFAULT = { explan: "기본양식-설명서", sibang: "기본양식-시방서" };
// 허용 태그·속성만 남김(id 는 책갈피, style 은 정해진 속성만)
function clean(html) {
const box = document.createElement("div");
box.innerHTML = String(html ?? "");
const walk = (node) => {
for (const child of [...node.childNodes]) {
if (child.nodeType === 3) continue;
if (child.nodeType !== 1 || !TAGS.has(child.tagName)) {
if (child.nodeType === 1) {
walk(child);
child.replaceWith(...child.childNodes);
} else child.remove();
continue;
}
for (const attr of [...child.attributes]) {
if (attr.name === "id" && child.tagName === "A") continue;
if (["colspan", "rowspan"].includes(attr.name)) continue;
if (attr.name === "style") {
const kept = attr.value
.split(";")
.map((p) => p.split(":").map((x) => x.trim()))
.filter(([k, v]) => k && v && STYLE.test(k) && !/url|expression/i.test(v))
.map(([k, v]) => `${k}:${v}`)
.join(";");
if (kept) child.setAttribute("style", kept);
else child.removeAttribute("style");
continue;
}
child.removeAttribute(attr.name);
}
walk(child);
}
};
walk(box);
return box.innerHTML;
}
const escText = (t) =>
String(t ?? "")
.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c])
.split(/\r?\n/)
.map((l) => `
${l || "
"}
`)
.join("");
function source(d) {
return d.html ? clean(d.html) : escText(d.text);
}
function printHtml(d) {
if (d.html) return `${clean(d.html)}
`;
return "";
}
function body(d) {
return `${[
["doc-par", "문단모양"],
["doc-table", "표만들기"],
["doc-table-style", "표모양"],
["doc-bookmark", "책갈피"],
["doc-forms", "양식목록"],
["doc-default", "기본양식으로 저장"],
]
.map(([a, l]) => ``)
.join(
"",
)}
${source(d)}
`;
}
const rich = () => document.getElementById("doc-rich");
function collect() {
const node = rich();
return {
html: clean(node.innerHTML),
text: node.innerText.replace(/\n{3,}/g, "\n\n").trimEnd(),
};
}
// 커서 자리 기억(대화 단추를 누르면 선택이 풀리므로)
let saved = null;
function remember() {
const sel = getSelection();
if (sel.rangeCount && rich()?.contains(sel.anchorNode)) saved = sel.getRangeAt(0).cloneRange();
}
function restore() {
const node = rich();
if (!node) return null;
node.focus();
const sel = getSelection();
sel.removeAllRanges();
if (saved && node.contains(saved.startContainer)) sel.addRange(saved);
else {
const r = document.createRange();
r.selectNodeContents(node);
r.collapse(false);
sel.addRange(r);
}
return sel.getRangeAt(0);
}
function insert(html) {
const range = restore();
range.deleteContents();
const frag = range.createContextualFragment(clean(html));
const last = frag.lastChild;
range.insertNode(frag);
if (last) {
range.setStartAfter(last);
range.collapse(true);
}
}
function insertText(text) {
const range = restore();
range.deleteContents();
range.insertNode(document.createTextNode(text));
range.collapse(false);
}
function blockAt(range) {
let n = range.startContainer;
if (n.nodeType === 3) n = n.parentNode;
const node = rich();
while (n && n !== node && !/^(P|DIV|H1|H2|H3|LI|TD|TH)$/.test(n.tagName)) n = n.parentNode;
if (!n || n === node) {
document.execCommand("formatBlock", false, "p");
n = getSelection().anchorNode;
if (n?.nodeType === 3) n = n.parentNode;
}
return n && n !== node ? n : null;
}
function tableAt(range) {
let n = range.startContainer;
while (n && n !== rich() && n.tagName !== "TABLE") n = n.parentNode;
return n?.tagName === "TABLE" ? n : null;
}
// 작은 입력 창(대화 안에 겹쳐 띄움)
function panel(html) {
let box = document.getElementById("doc-panel");
if (!box) {
box = document.createElement("div");
box.id = "doc-panel";
box.className = "business-tools";
rich().before(box);
}
box.innerHTML = html;
}
const v = (name) => document.querySelector(`#doc-panel [name="${name}"]`)?.value;
async function run(name, state, node) {
const d = state.docEditor;
if (name === "doc-par") {
remember();
panel(
`문단모양 들여쓰기(pt) 왼쪽 여백(pt) 줄간격(%) `,
);
} else if (name === "doc-par-ok") {
const block = blockAt(restore());
if (!block) throw Error("문단을 고르세요.");
block.style.textAlign = v("align");
block.style.textIndent = `${Number(v("indent")) || 0}pt`;
block.style.marginLeft = `${Number(v("margin")) || 0}pt`;
block.style.lineHeight = `${Number(v("line")) || 160}%`;
panel("");
} else if (name === "doc-table") {
remember();
panel(
`표만들기 줄 칸 `,
);
} else if (name === "doc-table-ok") {
const rows = Math.min(100, Math.max(1, Number(v("rows")) || 1)),
cols = Math.min(30, Math.max(1, Number(v("cols")) || 1));
insert(
`${Array.from({ length: rows }, () => `${Array.from({ length: cols }, () => '
| ').join("")}
`).join("")}
`,
);
panel("");
} else if (name === "doc-table-style") {
remember();
if (!saved || !tableAt(saved)) throw Error("표 안을 누른 뒤 표모양을 고르세요.");
panel(
`표모양 테두리(px) 칸 여백(pt) 너비(%) `,
);
} else if (name === "doc-table-style-ok") {
const table = tableAt(restore());
if (!table) throw Error("표 안을 누른 뒤 표모양을 고르세요.");
table.style.width = `${Number(v("width")) || 100}%`;
for (const cell of table.querySelectorAll("td,th")) {
cell.style.border = `${Number(v("border")) || 0}px solid #000`;
cell.style.padding = `${Number(v("pad")) || 0}pt`;
}
panel("");
} else if (name === "doc-bookmark") {
remember();
const marks = [...rich().querySelectorAll("a[id]")].map((a) => a.id);
panel(
`책갈피 이름 ${marks.map((m) => ``).join("")}`,
);
} else if (name === "doc-bookmark-ok") {
const mark = String(v("mark") ?? "").trim();
if (!/^[\w가-힣-]{1,40}$/.test(mark))
throw Error("책갈피 이름은 글자·숫자로 40자 안에서 적으세요.");
if (rich().querySelector(`a[id="${CSS.escape(mark)}"]`))
throw Error("같은 책갈피가 있습니다.");
insert(``);
panel("");
} else if (name === "doc-bookmark-go") {
rich()
.querySelector(`a[id="${CSS.escape(node.dataset.mark)}"]`)
?.scrollIntoView({ block: "center" });
} else if (name === "doc-forms") {
const list = (await EstApp.api("/api/forms")).filter((f) => f.kind === "document");
panel(
list.length
? `양식목록 ${list.map((f) => ``).join(" ")} `
: `저장한 양식이 없습니다. `,
);
} else if (name === "doc-form-load") {
const f = (await EstApp.api("/api/forms")).find(
(x) => x.kind === "document" && x.name === node.dataset.name,
);
if (!f) throw Error("양식이 없습니다.");
if (rich().innerText.trim() && !confirm("현재 내용을 양식으로 바꾸시겠습니까?")) return;
rich().innerHTML = f.data.html ? clean(f.data.html) : escText(f.data.text);
panel("");
} else if (name === "doc-form-delete") {
if (!confirm(`「${node.dataset.name}」 양식을 지우시겠습니까?`)) return;
await EstApp.api("/api/forms/delete", json({ kind: "document", name: node.dataset.name }));
return run("doc-forms", state, node);
} else if (name === "doc-form-save" || name === "doc-default") {
const fname = name === "doc-default" ? DEFAULT[d.kind] : String(v("form-name") ?? "").trim();
if (!fname) throw Error("양식 이름을 적으세요.");
await EstApp.api("/api/forms", json({ kind: "document", name: fname, data: collect() }));
panel(`「${EstApp.esc(fname)}」 양식으로 저장했습니다.`);
}
}
const json = (body) => ({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// 빈 문서를 열면 기본양식을 채운다
async function fill(kind) {
const node = rich();
if (!node || node.innerText.trim()) return;
const f = (await EstApp.api("/api/forms")).find(
(x) => x.kind === "document" && x.name === DEFAULT[kind],
);
if (f && !node.innerText.trim())
node.innerHTML = f.data.html ? clean(f.data.html) : escText(f.data.text);
}
const names = new Set([
"doc-par",
"doc-par-ok",
"doc-table",
"doc-table-ok",
"doc-table-style",
"doc-table-style-ok",
"doc-bookmark",
"doc-bookmark-ok",
"doc-bookmark-go",
"doc-forms",
"doc-form-load",
"doc-form-delete",
"doc-form-save",
"doc-default",
]);
function handle(name, state, node) {
if (!names.has(name)) return false;
run(name, state, node).catch((error) => panel(EstApp.esc(error.message)));
return true;
}
if (typeof document !== "undefined" && document.addEventListener)
document.addEventListener("selectionchange", () => {
if (rich()) remember();
});
return { clean, body, collect, printHtml, insertText, fill, handle };
})();