Files
Aislo/tools/est-test/native-doc-editor.js
T

330 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 원본 공사설명서·시방서 편집기(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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[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 };
})();