// 원본 공사설명서·시방서 편집기(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|font-size)$/; 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-font", "글자모양"], ["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-font") { remember(); panel( `글자모양 크기(pt) `, ); } else if (name === "doc-font-ok") { const range = restore(); if (range.collapsed) throw Error("글자를 고른 뒤 적용하세요."); if (node.dataset.cmd === "size") { const span = document.createElement("span"); span.style.fontSize = `${Math.min(40, Math.max(6, Number(v("size")) || 10))}pt`; span.append(range.extractContents()); range.insertNode(span); } else document.execCommand(node.dataset.cmd); remember(); } else if (name === "doc-clear") { if (!confirm("문서 내용을 모두 지우시겠습니까?")) return; rich().innerHTML = "


"; panel(""); } 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", "doc-font", "doc-font-ok", "doc-clear", ]); 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 }; })();