330 lines
14 KiB
JavaScript
330 lines
14 KiB
JavaScript
// 원본 공사설명서·시방서 편집기(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) => `<p>${l || "<br>"}</p>`)
|
||
.join("");
|
||
function source(d) {
|
||
return d.html ? clean(d.html) : escText(d.text);
|
||
}
|
||
function printHtml(d) {
|
||
if (d.html) return `<div class="document-text document-rich">${clean(d.html)}</div>`;
|
||
return "";
|
||
}
|
||
|
||
function body(d) {
|
||
return `<div class="business-tools">${[
|
||
["doc-font", "글자모양"],
|
||
["doc-par", "문단모양"],
|
||
["doc-table", "표만들기"],
|
||
["doc-table-style", "표모양"],
|
||
["doc-bookmark", "책갈피"],
|
||
["doc-forms", "양식목록"],
|
||
["doc-default", "기본양식으로 저장"],
|
||
]
|
||
.map(([a, l]) => `<button data-action="${a}">${l}</button>`)
|
||
.join(
|
||
"",
|
||
)}</div><div id="doc-rich" contenteditable="true" style="min-height:320px;max-height:460px;overflow:auto;border:1px solid #bbb;padding:8px;background:#fff">${source(d)}</div>`;
|
||
}
|
||
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(
|
||
`문단모양 <select name="align"><option value="left">왼쪽</option><option value="center">가운데</option><option value="right">오른쪽</option><option value="justify">양쪽</option></select> 들여쓰기(pt) <input name="indent" type="number" value="0" style="width:60px"> 왼쪽 여백(pt) <input name="margin" type="number" value="0" style="width:60px"> 줄간격(%) <input name="line" type="number" value="160" style="width:60px"> <button data-action="doc-par-ok">적용</button>`,
|
||
);
|
||
} 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(
|
||
`표만들기 줄 <input name="rows" type="number" value="3" min="1" max="100" style="width:60px"> 칸 <input name="cols" type="number" value="3" min="1" max="30" style="width:60px"> <button data-action="doc-table-ok">만들기</button>`,
|
||
);
|
||
} 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(
|
||
`<table style="border-collapse:collapse;width:100%"><tbody>${Array.from({ length: rows }, () => `<tr>${Array.from({ length: cols }, () => '<td style="border:1px solid #000;padding:2pt"><br></td>').join("")}</tr>`).join("")}</tbody></table><p><br></p>`,
|
||
);
|
||
panel("");
|
||
} else if (name === "doc-table-style") {
|
||
remember();
|
||
if (!saved || !tableAt(saved)) throw Error("표 안을 누른 뒤 표모양을 고르세요.");
|
||
panel(
|
||
`표모양 테두리(px) <input name="border" type="number" value="1" min="0" max="5" style="width:60px"> 칸 여백(pt) <input name="pad" type="number" value="2" style="width:60px"> 너비(%) <input name="width" type="number" value="100" style="width:60px"> <button data-action="doc-table-style-ok">적용</button>`,
|
||
);
|
||
} 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(
|
||
`책갈피 이름 <input name="mark"> <button data-action="doc-bookmark-ok">넣기</button> ${marks.map((m) => `<button data-action="doc-bookmark-go" data-mark="${EstApp.esc(m)}">${EstApp.esc(m)}</button>`).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(`<a id="${mark}"></a>`);
|
||
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(
|
||
`글자모양 <button data-action="doc-font-ok" data-cmd="bold"><b>굵게</b></button><button data-action="doc-font-ok" data-cmd="italic"><i>기울임</i></button><button data-action="doc-font-ok" data-cmd="underline"><u>밑줄</u></button> 크기(pt) <input name="size" type="number" value="10" min="6" max="40" style="width:60px"><button data-action="doc-font-ok" data-cmd="size">크기 적용</button> <button data-action="doc-clear">전체삭제</button>`,
|
||
);
|
||
} 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 = "<p><br></p>";
|
||
panel("");
|
||
} else if (name === "doc-forms") {
|
||
const list = (await EstApp.api("/api/forms")).filter((f) => f.kind === "document");
|
||
panel(
|
||
list.length
|
||
? `양식목록 ${list.map((f) => `<button data-action="doc-form-load" data-name="${EstApp.esc(f.name)}">${EstApp.esc(f.name)}</button><button data-action="doc-form-delete" data-name="${EstApp.esc(f.name)}" title="지우기">×</button>`).join(" ")} <input name="form-name" placeholder="새 양식 이름"><button data-action="doc-form-save">현재 문서를 양식으로</button>`
|
||
: `저장한 양식이 없습니다. <input name="form-name" placeholder="새 양식 이름"><button data-action="doc-form-save">현재 문서를 양식으로</button>`,
|
||
);
|
||
} 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 };
|
||
})();
|