legal-doc-editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/LegalEditor.d.ts +12 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +543 -0
- package/dist/io.d.ts +23 -0
- package/dist/numbering.d.ts +10 -0
- package/dist/style.css +2 -0
- package/dist/variable.d.ts +16 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 legal-doc-editor contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# legal-doc-editor
|
|
2
|
+
|
|
3
|
+
계약서·합의서 같은 **법률문서를 만드는 React 에디터**입니다.
|
|
4
|
+
|
|
5
|
+
- **조·항·호 자동 번호**: 조를 넣거나 옮기면 제1조, ①, 1. 번호가 알아서 다시 매겨져요
|
|
6
|
+
- **`{{변수}}` 채우기**: 본문에 `{{갑}}`을 입력하면 입력칸이 생기고, 값을 넣으면 문서에 반영돼요
|
|
7
|
+
- **한글(.hwp·.hwpx)·Word(.docx) 열기·저장**: 일반 문단으로 쓴 계약서도 "제N조", "①", "1." 패턴을 인식해서 조·항·호 구조로 바꿔요
|
|
8
|
+
- 저장은 `.docx`와 `.hwpx`예요. `.hwpx`는 한글 2014 이상에서 열리고, 한글에서 `.hwp`로 다시 저장할 수 있어요
|
|
9
|
+
- 파일 변환 라이브러리는 필요할 때만 불러와서 에디터 본체 번들은 가벼워요
|
|
10
|
+
- **인쇄·PDF**: 비어 있는 변수는 손으로 쓰는 밑줄 칸으로 인쇄돼요
|
|
11
|
+
|
|
12
|
+
에디터 엔진은 [Tiptap](https://tiptap.dev)(ProseMirror)을, 파일 변환은 [docx](https://github.com/dolanmiu/docx)·[mammoth](https://github.com/mwilliamson/mammoth.js)·[hwp-convert](https://www.npmjs.com/package/hwp-convert)를 씁니다.
|
|
13
|
+
|
|
14
|
+
## 설치
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm i legal-doc-editor
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 사용
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { LegalEditor } from 'legal-doc-editor'
|
|
24
|
+
import 'legal-doc-editor/style.css'
|
|
25
|
+
|
|
26
|
+
const template = `
|
|
27
|
+
<h1>비밀유지계약서</h1>
|
|
28
|
+
<p>{{갑}}과 {{을}}은 다음과 같이 계약을 체결한다.</p>
|
|
29
|
+
<h2>(목적)</h2>
|
|
30
|
+
<ol><li><p>첫째 항</p></li><li><p>둘째 항</p></li></ol>
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
export default function Page() {
|
|
34
|
+
return <LegalEditor content={template} onChange={(html) => save(html)} />
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 문서 구조
|
|
39
|
+
|
|
40
|
+
| 요소 | 의미 | 표시 |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `h1` | 문서 제목 | 가운데 정렬 |
|
|
43
|
+
| `h2` | 조 | 제N조 |
|
|
44
|
+
| `h3` | 소제목 (청구취지·고소이유 등) | 가운데 정렬 |
|
|
45
|
+
| `p[data-num="1"~"4"]` | 번호 문단 (소장식) | 1. → 가. → (1) → (가), 제목·조·소제목마다 1부터 |
|
|
46
|
+
| `table` | 표 | 표 |
|
|
47
|
+
| `ol > li` | 항 | ①, ② |
|
|
48
|
+
| `ol ol > li` | 호 | 1., 2. |
|
|
49
|
+
| `span[data-var]` 또는 `{{이름}}` | 변수 | 입력값 |
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
| 이름 | 설명 |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `<LegalEditor content values onChange onValuesChange editable />` | 에디터 컴포넌트 |
|
|
56
|
+
| `fillTemplate(html, values)` | 변수를 채운 완성본 HTML. `.legal-doc` 안에서 렌더하면 번호가 붙어요 |
|
|
57
|
+
| `toDocx(editor.getJSON(), values)` | `.docx` Blob 생성 |
|
|
58
|
+
| `toHwpx(editor.getJSON(), values)` | `.hwpx` Blob 생성 |
|
|
59
|
+
| `toPlainHtml(editor.getJSON(), values)` | 번호가 텍스트로 들어간 독립 HTML |
|
|
60
|
+
| `fromFile(file)` | `.docx` / `.hwp` / `.hwpx`를 에디터용 HTML로 변환 (`fromDocx`, `fromHwp`도 있음) |
|
|
61
|
+
| `normalizeLegalHtml(html)` | "제N조 / ① / 1." 문단을 조·항·호 구조로 변환 |
|
|
62
|
+
| `Variable` | 다른 Tiptap 에디터에 넣어 쓸 수 있는 변수 확장 |
|
|
63
|
+
| `Numbering` | 번호 문단 확장 (`setNumbering(1~4 \| null)`, Tab·Shift+Tab 단계 변경, 맨 앞 Backspace 해제) |
|
|
64
|
+
|
|
65
|
+
## 로드맵
|
|
66
|
+
|
|
67
|
+
- [x] HWP / HWPX 열기, HWPX 저장
|
|
68
|
+
- [ ] `.hwp` 바이너리로 바로 저장 ([rhwp](https://github.com/edwardkim/rhwp) 검토)
|
|
69
|
+
- [ ] 옛 Word `.doc` 열기
|
|
70
|
+
- [ ] 표
|
|
71
|
+
- [ ] 조항 라이브러리(자주 쓰는 조항 끼워 넣기)
|
|
72
|
+
|
|
73
|
+
## 개발
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm i
|
|
77
|
+
npm run dev # 데모: http://localhost:5173
|
|
78
|
+
npm run build # dist/
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## 라이선스
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Values } from './variable';
|
|
2
|
+
import './legal.css';
|
|
3
|
+
export interface LegalEditorProps {
|
|
4
|
+
/** 템플릿 HTML. {{변수}} 텍스트는 변수로 바뀜 */
|
|
5
|
+
content?: string;
|
|
6
|
+
/** 초기 입력값 */
|
|
7
|
+
values?: Values;
|
|
8
|
+
onChange?: (html: string) => void;
|
|
9
|
+
onValuesChange?: (values: Values) => void;
|
|
10
|
+
editable?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function LegalEditor({ content, values: initial, onChange, onValuesChange, editable }: LegalEditorProps): import("react").JSX.Element;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { LegalEditor } from './LegalEditor';
|
|
2
|
+
export type { LegalEditorProps } from './LegalEditor';
|
|
3
|
+
export { Variable, fillTemplate, toChips } from './variable';
|
|
4
|
+
export { Numbering } from './numbering';
|
|
5
|
+
export type { Values } from './variable';
|
|
6
|
+
export { toDocx, toHwpx, toPlainHtml, fromDocx, fromHwp, fromFile, normalizeLegalHtml } from './io';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { useEffect as e, useState as t } from "react";
|
|
2
|
+
import { EditorContent as n, useEditor as r } from "@tiptap/react";
|
|
3
|
+
import i from "@tiptap/starter-kit";
|
|
4
|
+
import { TableKit as a } from "@tiptap/extension-table";
|
|
5
|
+
import { Extension as o, Node as s, nodeInputRule as c, nodePasteRule as l } from "@tiptap/core";
|
|
6
|
+
import { jsx as u, jsxs as d } from "react/jsx-runtime";
|
|
7
|
+
//#region src/variable.ts
|
|
8
|
+
var f = s.create({
|
|
9
|
+
name: "variable",
|
|
10
|
+
group: "inline",
|
|
11
|
+
inline: !0,
|
|
12
|
+
atom: !0,
|
|
13
|
+
addAttributes() {
|
|
14
|
+
return { name: {
|
|
15
|
+
default: "",
|
|
16
|
+
parseHTML: (e) => e.getAttribute("data-var"),
|
|
17
|
+
renderHTML: (e) => ({ "data-var": e.name })
|
|
18
|
+
} };
|
|
19
|
+
},
|
|
20
|
+
addStorage() {
|
|
21
|
+
return {
|
|
22
|
+
values: {},
|
|
23
|
+
views: /* @__PURE__ */ new Set()
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
parseHTML() {
|
|
27
|
+
return [{ tag: "span[data-var]" }];
|
|
28
|
+
},
|
|
29
|
+
renderHTML({ node: e, HTMLAttributes: t }) {
|
|
30
|
+
return [
|
|
31
|
+
"span",
|
|
32
|
+
t,
|
|
33
|
+
e.attrs.name
|
|
34
|
+
];
|
|
35
|
+
},
|
|
36
|
+
addNodeView() {
|
|
37
|
+
return ({ node: e }) => {
|
|
38
|
+
let t = document.createElement("span"), n = () => {
|
|
39
|
+
let n = this.storage.values[e.attrs.name];
|
|
40
|
+
t.textContent = n || e.attrs.name, t.className = n ? "le-var" : "le-var le-var--empty";
|
|
41
|
+
};
|
|
42
|
+
return n(), this.storage.views.add(n), {
|
|
43
|
+
dom: t,
|
|
44
|
+
destroy: () => this.storage.views.delete(n)
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
addInputRules() {
|
|
49
|
+
return [c({
|
|
50
|
+
find: /\{\{[^{}<>"]+\}\}$/,
|
|
51
|
+
type: this.type,
|
|
52
|
+
getAttributes: (e) => ({ name: e[0].slice(2, -2).trim() })
|
|
53
|
+
})];
|
|
54
|
+
},
|
|
55
|
+
addPasteRules() {
|
|
56
|
+
return [l({
|
|
57
|
+
find: /\{\{[^{}<>"]+\}\}/g,
|
|
58
|
+
type: this.type,
|
|
59
|
+
getAttributes: (e) => ({ name: e[0].slice(2, -2).trim() })
|
|
60
|
+
})];
|
|
61
|
+
}
|
|
62
|
+
}), p = (e) => e.replace(/\{\{([^{}<>"]+)\}\}/g, (e, t) => `<span data-var="${t.trim().replace(/&/g, "&")}"></span>`);
|
|
63
|
+
function m(e, t) {
|
|
64
|
+
let n = new DOMParser().parseFromString(p(e), "text/html");
|
|
65
|
+
return n.querySelectorAll("span[data-var]").forEach((e) => {
|
|
66
|
+
let n = e.getAttribute("data-var");
|
|
67
|
+
e.replaceWith(t[n] || `[${n}]`);
|
|
68
|
+
}), n.body.innerHTML;
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/numbering.ts
|
|
72
|
+
var h = 4, g = o.create({
|
|
73
|
+
name: "numbering",
|
|
74
|
+
priority: 1e3,
|
|
75
|
+
addGlobalAttributes() {
|
|
76
|
+
return [{
|
|
77
|
+
types: ["paragraph"],
|
|
78
|
+
attributes: { num: {
|
|
79
|
+
default: null,
|
|
80
|
+
parseHTML: (e) => {
|
|
81
|
+
let t = Number(e.getAttribute("data-num"));
|
|
82
|
+
return t >= 1 && t <= h ? t : null;
|
|
83
|
+
},
|
|
84
|
+
renderHTML: (e) => e.num ? { "data-num": e.num } : {}
|
|
85
|
+
} }
|
|
86
|
+
}];
|
|
87
|
+
},
|
|
88
|
+
addCommands() {
|
|
89
|
+
return { setNumbering: (e) => ({ commands: t }) => t.updateAttributes("paragraph", { num: e }) };
|
|
90
|
+
},
|
|
91
|
+
addKeyboardShortcuts() {
|
|
92
|
+
let e = () => this.editor.getAttributes("paragraph").num, t = (t) => () => {
|
|
93
|
+
let n = e();
|
|
94
|
+
return !!n && this.editor.commands.setNumbering(Math.min(h, Math.max(1, n + t)));
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
Tab: t(1),
|
|
98
|
+
"Shift-Tab": t(-1),
|
|
99
|
+
Backspace: () => {
|
|
100
|
+
let { empty: t, $from: n } = this.editor.state.selection;
|
|
101
|
+
return !!e() && t && n.parentOffset === 0 && this.editor.commands.setNumbering(null);
|
|
102
|
+
},
|
|
103
|
+
Enter: () => !!e() && this.editor.state.selection.$from.parent.content.size === 0 && this.editor.commands.setNumbering(null)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}), _ = (e) => e < 20 ? String.fromCharCode(9312 + e) : `${e + 1}.`, v = "가나다라마바사아자차카타파하", y = (e) => {
|
|
107
|
+
let t = "";
|
|
108
|
+
for (; e > 0; e = Math.floor(e / 14)) t = v[--e % 14] + t;
|
|
109
|
+
return t;
|
|
110
|
+
}, b = (e, t) => [
|
|
111
|
+
`${t}.`,
|
|
112
|
+
`${y(t)}.`,
|
|
113
|
+
`(${t})`,
|
|
114
|
+
`(${y(t)})`
|
|
115
|
+
][e - 1];
|
|
116
|
+
function x(e, t) {
|
|
117
|
+
let n = [], r = 0, i = (e = []) => e.map((e) => {
|
|
118
|
+
if (e.type === "hardBreak") return {
|
|
119
|
+
text: "",
|
|
120
|
+
br: !0
|
|
121
|
+
};
|
|
122
|
+
if (e.type === "variable") return { text: t[e.attrs.name] || `[${e.attrs.name}]` };
|
|
123
|
+
let n = new Set(e.marks?.map((e) => e.type));
|
|
124
|
+
return {
|
|
125
|
+
text: e.text ?? "",
|
|
126
|
+
bold: n.has("bold"),
|
|
127
|
+
italics: n.has("italic"),
|
|
128
|
+
strike: n.has("strike"),
|
|
129
|
+
underline: n.has("underline")
|
|
130
|
+
};
|
|
131
|
+
}), a = (e) => e.map((e) => ({
|
|
132
|
+
...e,
|
|
133
|
+
bold: !0
|
|
134
|
+
})), o = (e, t) => e.content?.forEach((r, a) => r.content?.forEach((r, s) => {
|
|
135
|
+
if (r.type === "orderedList" || r.type === "bulletList") return o(r, t + 1);
|
|
136
|
+
let c = a + (e.attrs?.start ?? 1), l = s > 0 ? "" : e.type === "bulletList" ? "•" : t === 0 ? _(c - 1) : `${c}.`;
|
|
137
|
+
n.push({
|
|
138
|
+
kind: "para",
|
|
139
|
+
label: l,
|
|
140
|
+
depth: t + 1,
|
|
141
|
+
runs: i(r.content)
|
|
142
|
+
});
|
|
143
|
+
})), s = [
|
|
144
|
+
0,
|
|
145
|
+
0,
|
|
146
|
+
0,
|
|
147
|
+
0,
|
|
148
|
+
0
|
|
149
|
+
], c = (e = []) => {
|
|
150
|
+
for (let t of e) {
|
|
151
|
+
let e = t.type === "heading" ? t.attrs?.level : 0, l = t.type === "paragraph" ? t.attrs?.num : void 0;
|
|
152
|
+
e && s.fill(0), e === 1 ? n.push({
|
|
153
|
+
kind: "title",
|
|
154
|
+
label: "",
|
|
155
|
+
depth: 0,
|
|
156
|
+
runs: i(t.content)
|
|
157
|
+
}) : e === 2 ? n.push({
|
|
158
|
+
kind: "article",
|
|
159
|
+
label: `제${++r}조`,
|
|
160
|
+
depth: 0,
|
|
161
|
+
runs: a(i(t.content))
|
|
162
|
+
}) : e === 3 ? n.push({
|
|
163
|
+
kind: "section",
|
|
164
|
+
label: "",
|
|
165
|
+
depth: 0,
|
|
166
|
+
runs: a(i(t.content))
|
|
167
|
+
}) : t.type === "orderedList" || t.type === "bulletList" ? o(t, 0) : l ? (s[l]++, s.fill(0, l + 1), n.push({
|
|
168
|
+
kind: "para",
|
|
169
|
+
label: b(l, s[l]),
|
|
170
|
+
depth: l,
|
|
171
|
+
runs: i(t.content)
|
|
172
|
+
})) : t.type === "paragraph" ? n.push({
|
|
173
|
+
kind: "para",
|
|
174
|
+
label: "",
|
|
175
|
+
depth: 0,
|
|
176
|
+
runs: i(t.content)
|
|
177
|
+
}) : t.type === "table" ? n.push({
|
|
178
|
+
kind: "table",
|
|
179
|
+
rows: (t.content ?? []).map((e) => (e.content ?? []).map((e) => ({
|
|
180
|
+
runs: (e.content ?? []).flatMap((e, t) => [...t ? [{
|
|
181
|
+
text: "",
|
|
182
|
+
br: !0
|
|
183
|
+
}] : [], ...i(e.content)]),
|
|
184
|
+
colspan: e.attrs?.colspan ?? 1,
|
|
185
|
+
rowspan: e.attrs?.rowspan ?? 1
|
|
186
|
+
})))
|
|
187
|
+
}) : c(t.content);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
return c(e.content), n;
|
|
191
|
+
}
|
|
192
|
+
var S = (e) => e.replace(/[&<>"]/g, (e) => `&#${e.charCodeAt(0)};`), C = (e) => e.map((e) => {
|
|
193
|
+
if (e.br) return "<br>";
|
|
194
|
+
let t = S(e.text);
|
|
195
|
+
return e.bold && (t = `<b>${t}</b>`), e.italics && (t = `<i>${t}</i>`), e.underline && (t = `<u>${t}</u>`), e.strike && (t = `<s>${t}</s>`), t;
|
|
196
|
+
}).join("");
|
|
197
|
+
function w(e, t) {
|
|
198
|
+
return x(e, t).map((e) => {
|
|
199
|
+
if (e.kind === "table") {
|
|
200
|
+
let t = (e) => `<td${e.colspan > 1 ? ` colspan="${e.colspan}"` : ""}${e.rowspan > 1 ? ` rowspan="${e.rowspan}"` : ""}>${C(e.runs)}</td>`;
|
|
201
|
+
return `<table>${e.rows.map((e) => `<tr>${e.map(t).join("")}</tr>`).join("")}</table>`;
|
|
202
|
+
}
|
|
203
|
+
let t = (e.label ? e.kind === "article" ? `<b>${e.label}</b> ` : `${e.label} ` : "") + C(e.runs);
|
|
204
|
+
if (e.kind === "title") return `<h1>${t}</h1>`;
|
|
205
|
+
if (e.kind === "section") return `<h3>${t}</h3>`;
|
|
206
|
+
let n = e.label ? e.depth - 1 : e.depth;
|
|
207
|
+
return n > 0 ? `<p style="margin-left:${n * 2}em">${t}</p>` : `<p>${t}</p>`;
|
|
208
|
+
}).join("\n");
|
|
209
|
+
}
|
|
210
|
+
function T(e, t) {
|
|
211
|
+
for (let n of x(e, t)) if (n.kind === "title") return n.runs.map((e) => e.text).join("").trim() || "문서";
|
|
212
|
+
return "문서";
|
|
213
|
+
}
|
|
214
|
+
async function E(e, t) {
|
|
215
|
+
let { AlignmentType: n, Document: r, HeadingLevel: i, Packer: a, Paragraph: o, Table: s, TableCell: c, TableRow: l, TextRun: u, WidthType: d } = await import("docx"), f = (e) => new u({
|
|
216
|
+
text: e.text,
|
|
217
|
+
break: e.br ? 1 : void 0,
|
|
218
|
+
bold: e.bold,
|
|
219
|
+
italics: e.italics,
|
|
220
|
+
strike: e.strike,
|
|
221
|
+
underline: e.underline ? {} : void 0
|
|
222
|
+
}), p = x(e, t).map((e) => e.kind === "table" ? new s({
|
|
223
|
+
width: {
|
|
224
|
+
size: 100,
|
|
225
|
+
type: d.PERCENTAGE
|
|
226
|
+
},
|
|
227
|
+
rows: e.rows.map((e) => new l({ children: e.map((e) => new c({
|
|
228
|
+
columnSpan: e.colspan,
|
|
229
|
+
rowSpan: e.rowspan,
|
|
230
|
+
children: [new o({ children: e.runs.map(f) })]
|
|
231
|
+
})) }))
|
|
232
|
+
}) : new o({
|
|
233
|
+
heading: e.kind === "title" ? i.HEADING_1 : void 0,
|
|
234
|
+
alignment: e.kind === "section" ? n.CENTER : void 0,
|
|
235
|
+
spacing: e.kind === "article" || e.kind === "section" ? { before: 240 } : void 0,
|
|
236
|
+
indent: e.depth ? {
|
|
237
|
+
left: 400 * e.depth,
|
|
238
|
+
hanging: e.label ? 400 : 0
|
|
239
|
+
} : void 0,
|
|
240
|
+
children: [...e.label ? [new u({
|
|
241
|
+
text: `${e.label} `,
|
|
242
|
+
bold: e.kind === "article"
|
|
243
|
+
})] : [], ...e.runs.map(f)]
|
|
244
|
+
}));
|
|
245
|
+
return a.toBlob(new r({
|
|
246
|
+
title: T(e, t),
|
|
247
|
+
styles: {
|
|
248
|
+
default: { document: { run: {
|
|
249
|
+
font: "바탕",
|
|
250
|
+
size: 22
|
|
251
|
+
} } },
|
|
252
|
+
paragraphStyles: [{
|
|
253
|
+
id: "Heading1",
|
|
254
|
+
name: "Heading 1",
|
|
255
|
+
basedOn: "Normal",
|
|
256
|
+
next: "Normal",
|
|
257
|
+
run: {
|
|
258
|
+
bold: !0,
|
|
259
|
+
size: 32,
|
|
260
|
+
color: "000000"
|
|
261
|
+
},
|
|
262
|
+
paragraph: {
|
|
263
|
+
alignment: n.CENTER,
|
|
264
|
+
spacing: { after: 400 }
|
|
265
|
+
}
|
|
266
|
+
}]
|
|
267
|
+
},
|
|
268
|
+
sections: [{ children: p }]
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
async function D(e, t) {
|
|
272
|
+
let { htmlToHwpx: n } = await import("hwp-convert"), r = await n(w(e, t), { title: T(e, t) });
|
|
273
|
+
return new Blob([r], { type: "application/hwp+zip" });
|
|
274
|
+
}
|
|
275
|
+
var O = /^\s*제\s*\d+\s*조(?:의\s*\d+)?\s*/, k = /^\s*[①-⑳]\s*/, A = /^\s*(\d+)\.\s*/, j = /^(청구취지|청구원인|신청취지|신청이유|고소취지|고소이유|고발취지|고발이유|입증방법|증명방법|증거방법|증거서류|첨부서류|당사자관계|사건개요)$/, M = (e) => e.length <= 20 && (/^[가-힣](\s+[가-힣]){3,7}$/.test(e) || j.test(e.replace(/\s/g, ""))), N = /\[\s*([^[\]{}<>"\d=]{1,20}?)\s*\]/g, P = [
|
|
276
|
+
[/^\s*(\d{1,2})\.(?!\d)\s*/, Number],
|
|
277
|
+
[/^\s*([가나다라마바사아자차카타파하])\.\s*/, (e) => v.indexOf(e) + 1],
|
|
278
|
+
[/^\s*\((\d{1,2})\)\s*/, Number],
|
|
279
|
+
[/^\s*\(([가나다라마바사아자차카타파하])\)\s*/, (e) => v.indexOf(e) + 1]
|
|
280
|
+
];
|
|
281
|
+
function F(e) {
|
|
282
|
+
for (let [t, [n, r]] of P.entries()) {
|
|
283
|
+
let i = e.match(n);
|
|
284
|
+
if (i) return {
|
|
285
|
+
level: t + 1,
|
|
286
|
+
n: r(i[1]),
|
|
287
|
+
length: i[0].length
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
function I(e, t) {
|
|
293
|
+
let n = typeof t == "number" ? t : e.textContent.match(t)[0].length, r = e.ownerDocument.createTreeWalker(e, NodeFilter.SHOW_TEXT);
|
|
294
|
+
for (let e = r.nextNode(); e && n > 0; e = r.nextNode()) {
|
|
295
|
+
let t = Math.min(n, e.data.length);
|
|
296
|
+
e.data = e.data.slice(t), n -= t;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function L(e) {
|
|
300
|
+
let t = new DOMParser().parseFromString(e, "text/html");
|
|
301
|
+
for (let e of [...t.body.children]) e.tagName === "P" && !e.textContent.trim() && !e.querySelector("img") && e.remove();
|
|
302
|
+
let n = t.createTreeWalker(t.body, NodeFilter.SHOW_TEXT);
|
|
303
|
+
for (let e = n.nextNode(); e; e = n.nextNode()) {
|
|
304
|
+
let t = e.parentElement?.closest("p,li,td,th,h1,h2,h3")?.textContent?.trim();
|
|
305
|
+
e.data = e.data.replace(N, (e, n) => e.trim() === t ? e : `{{${n.trim()}}}`);
|
|
306
|
+
}
|
|
307
|
+
let r = t.body.firstElementChild, i = r?.textContent.trim() ?? "";
|
|
308
|
+
if (!t.querySelector("h1") && r?.tagName === "P" && i.length <= 30 && !/[.다]$/.test(i) && !O.test(i)) {
|
|
309
|
+
let e = t.createElement("h1");
|
|
310
|
+
e.textContent = i, r.replaceWith(e);
|
|
311
|
+
}
|
|
312
|
+
let a = null, o = [
|
|
313
|
+
0,
|
|
314
|
+
0,
|
|
315
|
+
0,
|
|
316
|
+
0,
|
|
317
|
+
0
|
|
318
|
+
], s = [
|
|
319
|
+
0,
|
|
320
|
+
0,
|
|
321
|
+
0,
|
|
322
|
+
0,
|
|
323
|
+
0
|
|
324
|
+
];
|
|
325
|
+
for (let e of [...t.body.children]) {
|
|
326
|
+
let n = e.textContent ?? "", r = n.replace(/\s+/g, " ").trim();
|
|
327
|
+
if (e.tagName === "P" && M(r)) {
|
|
328
|
+
let n = t.createElement("h3");
|
|
329
|
+
n.textContent = r, e.replaceWith(n), a = null, o.fill(0), s.fill(0);
|
|
330
|
+
} else if (e.tagName === "P" && O.test(n)) {
|
|
331
|
+
let n = t.createElement("h2");
|
|
332
|
+
n.innerHTML = e.innerHTML, I(n, O), e.replaceWith(n), a = null, o.fill(0), s.fill(0);
|
|
333
|
+
} else if (e.tagName === "P" && k.test(n)) {
|
|
334
|
+
let r = n.trim().charCodeAt(0) - 9311;
|
|
335
|
+
(!a || a.children.length + Number(a.getAttribute("start") ?? 1) !== r) && (e.before(a = t.createElement("ol")), r > 1 && a.setAttribute("start", String(r))), I(e, k);
|
|
336
|
+
let i = t.createElement("li");
|
|
337
|
+
i.append(e), a.append(i);
|
|
338
|
+
} else if (e.tagName === "P" && a && A.test(n) && Number(n.match(A)[1]) === (a.lastElementChild.querySelector(":scope > ol")?.children.length ?? 0) + 1) {
|
|
339
|
+
let n = a.lastElementChild, r = n.querySelector(":scope > ol") ?? n.appendChild(t.createElement("ol"));
|
|
340
|
+
I(e, A);
|
|
341
|
+
let i = t.createElement("li");
|
|
342
|
+
i.append(e), r.append(i);
|
|
343
|
+
} else {
|
|
344
|
+
a = null;
|
|
345
|
+
let t = e.tagName === "P" ? F(n) : null;
|
|
346
|
+
if (!t) continue;
|
|
347
|
+
s[t.level] && t.n === s[t.level] + 1 ? s[t.level]++ : t.n === o[t.level] + 1 ? (o[t.level]++, o.fill(0, t.level + 1), s.fill(0), I(e, t.length), e.setAttribute("data-num", String(t.level))) : s[t.level] = +(t.n === 1);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return t.body.innerHTML;
|
|
351
|
+
}
|
|
352
|
+
async function R(e) {
|
|
353
|
+
let { default: t } = await import("mammoth"), { value: n } = await t.convertToHtml({ arrayBuffer: await e.arrayBuffer() });
|
|
354
|
+
return L(n);
|
|
355
|
+
}
|
|
356
|
+
async function z(e) {
|
|
357
|
+
let { detectFormat: t, hwpToHwpx: n, HwpxReader: r } = await import("hwp-convert"), i = new Uint8Array(await e.arrayBuffer());
|
|
358
|
+
t(i) === "hwp" && (i = new Uint8Array(await n(i)));
|
|
359
|
+
let a = new r();
|
|
360
|
+
return await a.loadFromArrayBuffer(i.buffer), L(await a.extractHtml());
|
|
361
|
+
}
|
|
362
|
+
function B(e) {
|
|
363
|
+
return /\.docx$/i.test(e.name) ? R(e) : /\.hwpx?$/i.test(e.name) ? z(e) : Promise.reject(/* @__PURE__ */ Error(".docx, .hwp, .hwpx 파일만 열 수 있어요"));
|
|
364
|
+
}
|
|
365
|
+
//#endregion
|
|
366
|
+
//#region src/LegalEditor.tsx
|
|
367
|
+
function V(e) {
|
|
368
|
+
let t = /* @__PURE__ */ new Set();
|
|
369
|
+
return e.state.doc.descendants((e) => {
|
|
370
|
+
e.type.name === "variable" && t.add(e.attrs.name);
|
|
371
|
+
}), [...t];
|
|
372
|
+
}
|
|
373
|
+
function H() {
|
|
374
|
+
let e = document.documentElement;
|
|
375
|
+
e.classList.add("le-printing"), window.addEventListener("afterprint", () => e.classList.remove("le-printing"), { once: !0 }), window.print();
|
|
376
|
+
}
|
|
377
|
+
function U(e, t) {
|
|
378
|
+
let n = document.createElement("a");
|
|
379
|
+
n.href = URL.createObjectURL(e), n.download = t, n.click(), setTimeout(() => URL.revokeObjectURL(n.href), 1e3);
|
|
380
|
+
}
|
|
381
|
+
function W({ content: o = "", values: s = {}, onChange: c, onValuesChange: l, editable: m = !0 }) {
|
|
382
|
+
let [h, _] = t(s), [v, y] = t([]), b = r({
|
|
383
|
+
extensions: [
|
|
384
|
+
i.configure({ heading: { levels: [
|
|
385
|
+
1,
|
|
386
|
+
2,
|
|
387
|
+
3
|
|
388
|
+
] } }),
|
|
389
|
+
a,
|
|
390
|
+
f,
|
|
391
|
+
g
|
|
392
|
+
],
|
|
393
|
+
content: p(o),
|
|
394
|
+
editable: m,
|
|
395
|
+
immediatelyRender: !1,
|
|
396
|
+
editorProps: { attributes: { class: "legal-doc" } },
|
|
397
|
+
onCreate: ({ editor: e }) => y(V(e)),
|
|
398
|
+
onUpdate: ({ editor: e }) => {
|
|
399
|
+
y(V(e)), c?.(e.getHTML());
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
e(() => {
|
|
403
|
+
b && (b.storage.variable.values = h, b.storage.variable.views.forEach((e) => e()));
|
|
404
|
+
}, [b, h]);
|
|
405
|
+
let x = (e, t) => {
|
|
406
|
+
let n = {
|
|
407
|
+
...h,
|
|
408
|
+
[e]: t
|
|
409
|
+
};
|
|
410
|
+
_(n), l?.(n);
|
|
411
|
+
}, S = () => {
|
|
412
|
+
let e = window.prompt("변수 이름 (예: 갑, 계약일)")?.replace(/[{}<>"]/g, "").trim();
|
|
413
|
+
e && b?.chain().focus().insertContent({
|
|
414
|
+
type: "variable",
|
|
415
|
+
attrs: { name: e }
|
|
416
|
+
}).run();
|
|
417
|
+
}, C = async (e) => {
|
|
418
|
+
let t = e.target.files?.[0];
|
|
419
|
+
if (e.target.value = "", t && b) try {
|
|
420
|
+
b.commands.setContent(p(await B(t)), { emitUpdate: !0 });
|
|
421
|
+
} catch (e) {
|
|
422
|
+
window.alert(`파일을 열 수 없어요: ${e.message}`);
|
|
423
|
+
}
|
|
424
|
+
}, w = async (e, t) => {
|
|
425
|
+
let n = b.state.doc.firstChild?.textContent.trim() || "문서";
|
|
426
|
+
U(await e(b.getJSON(), h), `${n}.${t}`);
|
|
427
|
+
}, T = () => b.chain().focus(), O = (e) => T().setNumbering(b.getAttributes("paragraph").num === e ? null : e).run();
|
|
428
|
+
return /* @__PURE__ */ d("div", {
|
|
429
|
+
className: "le-root",
|
|
430
|
+
children: [/* @__PURE__ */ d("div", {
|
|
431
|
+
className: "le-main",
|
|
432
|
+
children: [m && b && /* @__PURE__ */ d("div", {
|
|
433
|
+
className: "le-toolbar",
|
|
434
|
+
children: [
|
|
435
|
+
/* @__PURE__ */ u("button", {
|
|
436
|
+
type: "button",
|
|
437
|
+
onClick: () => T().toggleHeading({ level: 1 }).run(),
|
|
438
|
+
children: "제목"
|
|
439
|
+
}),
|
|
440
|
+
/* @__PURE__ */ u("button", {
|
|
441
|
+
type: "button",
|
|
442
|
+
onClick: () => T().toggleHeading({ level: 3 }).run(),
|
|
443
|
+
children: "소제목"
|
|
444
|
+
}),
|
|
445
|
+
/* @__PURE__ */ u("button", {
|
|
446
|
+
type: "button",
|
|
447
|
+
onClick: () => T().toggleHeading({ level: 2 }).run(),
|
|
448
|
+
children: "조"
|
|
449
|
+
}),
|
|
450
|
+
[
|
|
451
|
+
"1.",
|
|
452
|
+
"가.",
|
|
453
|
+
"(1)",
|
|
454
|
+
"(가)"
|
|
455
|
+
].map((e, t) => /* @__PURE__ */ u("button", {
|
|
456
|
+
type: "button",
|
|
457
|
+
title: "번호 문단 — Tab·Shift+Tab 단계 변경, 맨 앞 Backspace 해제",
|
|
458
|
+
onClick: () => O(t + 1),
|
|
459
|
+
children: e
|
|
460
|
+
}, e)),
|
|
461
|
+
/* @__PURE__ */ u("button", {
|
|
462
|
+
type: "button",
|
|
463
|
+
onClick: () => T().toggleOrderedList().run(),
|
|
464
|
+
children: "항"
|
|
465
|
+
}),
|
|
466
|
+
/* @__PURE__ */ u("button", {
|
|
467
|
+
type: "button",
|
|
468
|
+
onClick: () => T().sinkListItem("listItem").run(),
|
|
469
|
+
children: "호 →"
|
|
470
|
+
}),
|
|
471
|
+
/* @__PURE__ */ u("button", {
|
|
472
|
+
type: "button",
|
|
473
|
+
onClick: () => T().liftListItem("listItem").run(),
|
|
474
|
+
children: "← 내어쓰기"
|
|
475
|
+
}),
|
|
476
|
+
/* @__PURE__ */ u("button", {
|
|
477
|
+
type: "button",
|
|
478
|
+
onClick: () => T().toggleBold().run(),
|
|
479
|
+
children: /* @__PURE__ */ u("b", { children: "B" })
|
|
480
|
+
}),
|
|
481
|
+
/* @__PURE__ */ d("button", {
|
|
482
|
+
type: "button",
|
|
483
|
+
onClick: S,
|
|
484
|
+
children: ["{{ }}", " 변수"]
|
|
485
|
+
}),
|
|
486
|
+
/* @__PURE__ */ u("button", {
|
|
487
|
+
type: "button",
|
|
488
|
+
onClick: () => T().insertTable({
|
|
489
|
+
rows: 3,
|
|
490
|
+
cols: 3,
|
|
491
|
+
withHeaderRow: !1
|
|
492
|
+
}).run(),
|
|
493
|
+
children: "표"
|
|
494
|
+
}),
|
|
495
|
+
/* @__PURE__ */ u("span", { className: "le-spacer" }),
|
|
496
|
+
/* @__PURE__ */ d("label", {
|
|
497
|
+
className: "le-btn",
|
|
498
|
+
children: ["열기", /* @__PURE__ */ u("input", {
|
|
499
|
+
type: "file",
|
|
500
|
+
accept: ".docx,.hwp,.hwpx",
|
|
501
|
+
hidden: !0,
|
|
502
|
+
onChange: C
|
|
503
|
+
})]
|
|
504
|
+
}),
|
|
505
|
+
/* @__PURE__ */ u("button", {
|
|
506
|
+
type: "button",
|
|
507
|
+
onClick: () => w(E, "docx"),
|
|
508
|
+
children: "Word 저장"
|
|
509
|
+
}),
|
|
510
|
+
/* @__PURE__ */ u("button", {
|
|
511
|
+
type: "button",
|
|
512
|
+
onClick: () => w(D, "hwpx"),
|
|
513
|
+
children: "한글 저장"
|
|
514
|
+
}),
|
|
515
|
+
/* @__PURE__ */ u("button", {
|
|
516
|
+
type: "button",
|
|
517
|
+
onClick: H,
|
|
518
|
+
children: "인쇄 · PDF"
|
|
519
|
+
})
|
|
520
|
+
]
|
|
521
|
+
}), /* @__PURE__ */ u(n, { editor: b })]
|
|
522
|
+
}), /* @__PURE__ */ d("aside", {
|
|
523
|
+
className: "le-panel",
|
|
524
|
+
children: [
|
|
525
|
+
/* @__PURE__ */ u("h3", { children: "입력값" }),
|
|
526
|
+
v.length === 0 && /* @__PURE__ */ d("p", {
|
|
527
|
+
className: "le-hint",
|
|
528
|
+
children: [
|
|
529
|
+
"본문에 ",
|
|
530
|
+
"{{당사자}}",
|
|
531
|
+
"처럼 입력하면 변수가 생겨요."
|
|
532
|
+
]
|
|
533
|
+
}),
|
|
534
|
+
v.map((e) => /* @__PURE__ */ d("label", { children: [e, /* @__PURE__ */ u("input", {
|
|
535
|
+
value: h[e] ?? "",
|
|
536
|
+
onChange: (t) => x(e, t.target.value)
|
|
537
|
+
})] }, e))
|
|
538
|
+
]
|
|
539
|
+
})]
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
//#endregion
|
|
543
|
+
export { W as LegalEditor, g as Numbering, f as Variable, m as fillTemplate, R as fromDocx, B as fromFile, z as fromHwp, L as normalizeLegalHtml, p as toChips, E as toDocx, D as toHwpx, w as toPlainHtml };
|
package/dist/io.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { JSONContent } from '@tiptap/core';
|
|
2
|
+
import type { Values } from './variable';
|
|
3
|
+
/** 번호가 텍스트로 박힌 독립 HTML — CSS 없이도 조·항·호가 보여서 메일·다른 변환기에 넘기기 좋음 */
|
|
4
|
+
export declare function toPlainHtml(doc: JSONContent, values: Values): string;
|
|
5
|
+
/** 에디터 JSON(editor.getJSON()) + 입력값 → .docx Blob */
|
|
6
|
+
export declare function toDocx(doc: JSONContent, values: Values): Promise<Blob>;
|
|
7
|
+
/** 에디터 JSON + 입력값 → .hwpx Blob (한글 2014 이상에서 열림) */
|
|
8
|
+
export declare function toHwpx(doc: JSONContent, values: Values): Promise<Blob>;
|
|
9
|
+
/**
|
|
10
|
+
* 문단으로만 된 법률문서 HTML(한글·워드에서 온 것)을 에디터 구조로 정리.
|
|
11
|
+
* - 빈 줄 문단 제거, 짧은 첫 줄 → 제목(h1), "청 구 취 지" 같은 소제목 → h3, [빈칸] → {{변수}}
|
|
12
|
+
* - "제N조…" → 조(h2), 연속된 "①…" → 항(ol), 항 바로 뒤 "1. …" → 호(중첩 ol)
|
|
13
|
+
* - 소장식 "1. / 가. / (1) / (가)" → 번호 문단(p[data-num]). 원문 번호가 자동 번호와 같을 때만 바꿈
|
|
14
|
+
* ponytail: 원문 조 번호는 버리고 자동 번호로 다시 매김 (제3조의2 같은 가지번호는 순번으로 바뀜).
|
|
15
|
+
* "1)·가)" 형식은 아직 글자로 둠
|
|
16
|
+
*/
|
|
17
|
+
export declare function normalizeLegalHtml(html: string): string;
|
|
18
|
+
/** .docx → 에디터용 HTML (서식은 문단·굵게·목록·표 수준으로 단순화됨) */
|
|
19
|
+
export declare function fromDocx(file: Blob): Promise<string>;
|
|
20
|
+
/** .hwp / .hwpx → 에디터용 HTML. .hwp는 hwpx로 바꾼 뒤 읽음 (암호화·배포용·HWP 3.0은 에러) */
|
|
21
|
+
export declare function fromHwp(file: Blob): Promise<string>;
|
|
22
|
+
/** 확장자로 골라 여는 헬퍼: .docx / .hwp / .hwpx */
|
|
23
|
+
export declare function fromFile(file: File): Promise<string>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core';
|
|
2
|
+
declare module '@tiptap/core' {
|
|
3
|
+
interface Commands<ReturnType> {
|
|
4
|
+
numbering: {
|
|
5
|
+
/** 선택한 문단을 번호 문단으로 (1: 1. / 2: 가. / 3: (1) / 4: (가)), null이면 해제 */
|
|
6
|
+
setNumbering: (level: number | null) => ReturnType;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export declare const Numbering: Extension<any, any>;
|
package/dist/style.css
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
@counter-style le-circled{system:fixed;symbols:"①" "②" "③" "④" "⑤" "⑥" "⑦" "⑧" "⑨" "⑩" "⑪" "⑫" "⑬" "⑭" "⑮" "⑯" "⑰" "⑱" "⑲" "⑳";suffix:" "}.legal-doc{counter-reset:le-article le-n1 le-n2 le-n3 le-n4;color:#111;background:#fff;outline:none;max-width:760px;margin:0 auto;padding:56px 64px;font-family:Noto Serif KR,Nanum Myeongjo,Batang,serif;font-size:15px;line-height:1.85}.legal-doc h1{text-align:center;letter-spacing:.25em;margin:0 0 1.6em;font-size:1.6em}.legal-doc h2{counter-increment:le-article;margin:1.6em 0 .3em;font-size:1em}.legal-doc h2:before{content:"제" counter(le-article) "조 "}.legal-doc p{margin:0 0 .4em}.legal-doc ol{margin:0 0 .4em;padding-left:1.8em;list-style:le-circled}.legal-doc ol ol{list-style:decimal}.legal-doc li p{margin:0}.legal-doc h3{text-align:center;letter-spacing:.3em;margin:1.8em 0 .8em;font-size:1.05em}.legal-doc>h1,.legal-doc>h2,.legal-doc>h3{counter-set:le-n1 le-n2 le-n3 le-n4}.legal-doc>p[data-num]{padding-left:calc(var(--le-num-indent,0em) + 2em);text-indent:-2em}.legal-doc>p[data-num]:before{text-indent:0;min-width:2em;display:inline-block}.legal-doc>p[data-num="1"]{counter-increment:le-n1;counter-set:le-n2 le-n3 le-n4}.legal-doc>p[data-num="2"]{counter-increment:le-n2;counter-set:le-n3 le-n4;--le-num-indent:1.2em}.legal-doc>p[data-num="3"]{counter-increment:le-n3;counter-set:le-n4;--le-num-indent:2.4em}.legal-doc>p[data-num="4"]{counter-increment:le-n4;--le-num-indent:3.6em}.legal-doc>p[data-num="1"]:before{content:counter(le-n1) "."}.legal-doc>p[data-num="2"]:before{content:counter(le-n2, hangul) "."}.legal-doc>p[data-num="3"]:before{content:"(" counter(le-n3) ")"}.legal-doc>p[data-num="4"]:before{content:"(" counter(le-n4, hangul) ")"}.legal-doc .tableWrapper{overflow-x:auto}.legal-doc table{border-collapse:collapse;width:100%;margin:.6em 0;font-size:.93em}.legal-doc td,.legal-doc th{vertical-align:top;border:1px solid #555;padding:3px 6px}.legal-doc td p,.legal-doc th p{margin:0}.legal-doc .selectedCell{background:#e5edff}.le-root{background:#eef0f3;grid-template-columns:minmax(0,1fr) 260px;gap:16px;padding:16px;font:14px/1.5 system-ui,sans-serif;display:grid}.le-main{min-width:0}.le-toolbar{z-index:1;background:#fff;border-radius:8px;flex-wrap:wrap;gap:4px;max-width:760px;margin:0 auto 8px;padding:6px;display:flex;position:sticky;top:0;box-shadow:0 1px 2px #0001}.le-toolbar button,.le-toolbar .le-btn{cursor:pointer;font:inherit;background:#fff;border:1px solid #d0d4da;border-radius:6px;padding:4px 10px}.le-toolbar button:hover{background:#f3f5f8}.le-spacer{flex:1}.le-main .legal-doc{min-height:900px;box-shadow:0 1px 4px #0002}.le-var{color:#1d3f94;background:#e5edff;border-radius:3px;padding:0 3px}.le-var--empty{color:#8a5a00;background:#fff1c2}.le-panel{background:#fff;border-radius:8px;align-self:start;padding:14px;position:sticky;top:16px;box-shadow:0 1px 2px #0001}.le-panel h3{margin:0 0 10px;font-size:14px}.le-panel label{color:#444;gap:3px;margin-bottom:10px;font-size:13px;display:grid}.le-panel input{font:inherit;border:1px solid #d0d4da;border-radius:6px;padding:6px 8px}.le-hint{color:#777;margin:0;font-size:13px}@media (width<=800px){.le-root{grid-template-columns:1fr}.legal-doc{padding:32px 24px}}@media print{.le-printing body *{visibility:hidden}.le-printing .legal-doc,.le-printing .legal-doc *{visibility:visible}.le-printing .legal-doc{width:100%;max-width:none;box-shadow:none;padding:0;position:absolute;top:0;left:0}.le-printing .le-var{color:inherit;background:0 0;padding:0}.le-printing .le-var--empty{color:#0000;border-bottom:1px solid #000}}
|
|
2
|
+
/*$vite$:1*/
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Node } from '@tiptap/core';
|
|
2
|
+
export type Values = Record<string, string>;
|
|
3
|
+
export interface VariableStorage {
|
|
4
|
+
values: Values;
|
|
5
|
+
views: Set<() => void>;
|
|
6
|
+
}
|
|
7
|
+
declare module '@tiptap/core' {
|
|
8
|
+
interface Storage {
|
|
9
|
+
variable: VariableStorage;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export declare const Variable: Node<object, VariableStorage>;
|
|
13
|
+
/** 원문 HTML의 {{이름}} 텍스트를 변수 노드로 바꿈 */
|
|
14
|
+
export declare const toChips: (html: string) => string;
|
|
15
|
+
/** 에디터 HTML + 입력값 → 완성 문서 HTML. 비어 있는 값은 [이름]으로 남김. .legal-doc 안에서 렌더하면 조항 번호가 붙음 */
|
|
16
|
+
export declare function fillTemplate(html: string, values: Values): string;
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "legal-doc-editor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "법률문서(계약서·소장·고소장) 생성용 React 에디터 — 조항·번호 자동 매김, {{변수}} 채우기, 한글(hwp·hwpx)·Word(docx) 열기·저장",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/hwondev/legal-doc-editor.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/hwondev/legal-doc-editor#readme",
|
|
11
|
+
"bugs": "https://github.com/hwondev/legal-doc-editor/issues",
|
|
12
|
+
"keywords": ["react", "editor", "legal", "contract", "korean", "hwp", "hwpx", "docx", "tiptap", "법률문서", "계약서", "소장"],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"module": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./style.css": "./dist/style.css"
|
|
25
|
+
},
|
|
26
|
+
"sideEffects": [
|
|
27
|
+
"*.css"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"dev": "vite",
|
|
31
|
+
"build": "vite build && tsc -p tsconfig.build.json",
|
|
32
|
+
"check": "node scripts/check.ts",
|
|
33
|
+
"prepublishOnly": "npm run check && npm run build"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"react": ">=18",
|
|
37
|
+
"react-dom": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@tiptap/core": "^3.31.3",
|
|
41
|
+
"@tiptap/extension-table": "^3.31.3",
|
|
42
|
+
"@tiptap/pm": "^3.31.3",
|
|
43
|
+
"@tiptap/react": "^3.31.3",
|
|
44
|
+
"@tiptap/starter-kit": "^3.31.3",
|
|
45
|
+
"docx": "^9.7.1",
|
|
46
|
+
"hwp-convert": "^1.13.0",
|
|
47
|
+
"mammoth": "^1.12.3"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/react": "^19.3.0",
|
|
51
|
+
"@types/react-dom": "^19.3.0",
|
|
52
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
53
|
+
"react": "^19.3.0",
|
|
54
|
+
"react-dom": "^19.3.0",
|
|
55
|
+
"typescript": "^7.0.2",
|
|
56
|
+
"vite": "^8.3.0"
|
|
57
|
+
}
|
|
58
|
+
}
|