mdsquare 0.0.0-stage → 0.2.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 +218 -2
- package/dist/callout.d.ts +17 -0
- package/dist/callout.js +541 -0
- package/dist/calloutHeader.d.ts +47 -0
- package/dist/calloutHeader.js +75 -0
- package/dist/codeBlock.d.ts +78 -0
- package/dist/codeBlock.js +686 -0
- package/dist/codeTheme.d.ts +47 -0
- package/dist/codeTheme.js +394 -0
- package/dist/delimiter.d.ts +20 -0
- package/dist/delimiter.js +73 -0
- package/dist/editor.d.ts +132 -0
- package/dist/editor.js +225 -0
- package/dist/emphasis.d.ts +6 -0
- package/dist/emphasis.js +101 -0
- package/dist/escape.d.ts +4 -0
- package/dist/escape.js +39 -0
- package/dist/extensions.d.ts +74 -0
- package/dist/extensions.js +81 -0
- package/dist/footnote.d.ts +24 -0
- package/dist/footnote.js +456 -0
- package/dist/footnoteSyntax.d.ts +23 -0
- package/dist/footnoteSyntax.js +107 -0
- package/dist/hashtag.d.ts +4 -0
- package/dist/hashtag.js +64 -0
- package/dist/hashtagSyntax.d.ts +36 -0
- package/dist/hashtagSyntax.js +99 -0
- package/dist/heading.d.ts +7 -0
- package/dist/heading.js +159 -0
- package/dist/highlight.d.ts +5 -0
- package/dist/highlight.js +6 -0
- package/dist/horizontalRule.d.ts +21 -0
- package/dist/horizontalRule.js +142 -0
- package/dist/image.d.ts +58 -0
- package/dist/image.js +344 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/inlineCode.d.ts +4 -0
- package/dist/inlineCode.js +68 -0
- package/dist/link.d.ts +47 -0
- package/dist/link.js +217 -0
- package/dist/list.d.ts +14 -0
- package/dist/list.js +136 -0
- package/dist/listEnter.d.ts +20 -0
- package/dist/listEnter.js +72 -0
- package/dist/listIndent.d.ts +23 -0
- package/dist/listIndent.js +335 -0
- package/dist/markVision.d.ts +17 -0
- package/dist/markVision.js +56 -0
- package/dist/math.d.ts +45 -0
- package/dist/math.js +233 -0
- package/dist/mathSyntax.d.ts +24 -0
- package/dist/mathSyntax.js +179 -0
- package/dist/mention.d.ts +4 -0
- package/dist/mention.js +58 -0
- package/dist/mentionSyntax.d.ts +14 -0
- package/dist/mentionSyntax.js +71 -0
- package/dist/quote.d.ts +9 -0
- package/dist/quote.js +122 -0
- package/dist/setup.d.ts +13 -0
- package/dist/setup.js +40 -0
- package/dist/style.css +6 -0
- package/dist/style.css.d.ts +2 -0
- package/dist/table.d.ts +38 -0
- package/dist/table.js +960 -0
- package/dist/tableEdit.d.ts +30 -0
- package/dist/tableEdit.js +305 -0
- package/dist/tableMenu.d.ts +65 -0
- package/dist/tableMenu.js +1125 -0
- package/dist/tableModel.d.ts +168 -0
- package/dist/tableModel.js +433 -0
- package/dist/tableState.d.ts +36 -0
- package/dist/tableState.js +96 -0
- package/dist/task.d.ts +13 -0
- package/dist/task.js +179 -0
- package/dist/textMate.d.ts +49 -0
- package/dist/textMate.js +284 -0
- package/dist/tikz.d.ts +56 -0
- package/dist/tikz.js +343 -0
- package/dist/underline.d.ts +5 -0
- package/dist/underline.js +6 -0
- package/dist/utils/blockMargin.d.ts +6 -0
- package/dist/utils/blockMargin.js +6 -0
- package/dist/utils/codePoint.d.ts +13 -0
- package/dist/utils/codePoint.js +43 -0
- package/dist/utils/container.d.ts +10 -0
- package/dist/utils/container.js +10 -0
- package/dist/utils/reveal.d.ts +99 -0
- package/dist/utils/reveal.js +161 -0
- package/dist/utils/selection.d.ts +33 -0
- package/dist/utils/selection.js +45 -0
- package/dist/utils/syntaxTree.d.ts +18 -0
- package/dist/utils/syntaxTree.js +23 -0
- package/package.json +79 -4
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
|
|
2
|
+
import { Prec, } from "@codemirror/state";
|
|
3
|
+
import { keymap } from "@codemirror/view";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// 구문 트리 도우미
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
/**
|
|
8
|
+
* 문서 끝까지 파싱된 트리 (시간 내에 끝나지 않으면 현재까지의 트리).
|
|
9
|
+
* 커서 위치까지만 파싱하면, 초기 파싱이 시간 예산(20ms) 안에 끝나지 않았을 때 같은 목록의
|
|
10
|
+
* 뒤따르는 항목(번호 다시 매기기·형제 정렬에 필요)이 트리에 없을 수 있어 문서 끝까지 확보합니다.
|
|
11
|
+
* (Tab은 한 번씩 실행되고, 보통은 백그라운드 파싱이 이미 끝나 있어 비용이 거의 없습니다)
|
|
12
|
+
*/
|
|
13
|
+
function fullTree(state) {
|
|
14
|
+
return ensureSyntaxTree(state, state.doc.length, 200) ?? syntaxTree(state);
|
|
15
|
+
}
|
|
16
|
+
const markOf = (item) => item.getChild("ListMark");
|
|
17
|
+
/** 같은 목록 안에서 바로 앞의 ListItem */
|
|
18
|
+
function prevItem(item) {
|
|
19
|
+
for (let n = item.prevSibling; n; n = n.prevSibling)
|
|
20
|
+
if (n.name === "ListItem")
|
|
21
|
+
return n;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/** 같은 목록 안에서 바로 다음 ListItem */
|
|
25
|
+
function nextItem(item) {
|
|
26
|
+
for (let n = item.nextSibling; n; n = n.nextSibling)
|
|
27
|
+
if (n.name === "ListItem")
|
|
28
|
+
return n;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/** 목록의 ListItem 자식들 */
|
|
32
|
+
function itemsOf(list) {
|
|
33
|
+
return list.getChildren("ListItem");
|
|
34
|
+
}
|
|
35
|
+
/** 기호(ListMark)가 해당 줄에 있는 가장 안쪽 ListItem. 없으면 null */
|
|
36
|
+
function itemOnLine(tree, from, to) {
|
|
37
|
+
let mark = null;
|
|
38
|
+
tree.iterate({
|
|
39
|
+
from,
|
|
40
|
+
to,
|
|
41
|
+
enter(node) {
|
|
42
|
+
if (node.name === "ListMark" && node.from >= from && node.from <= to)
|
|
43
|
+
mark = node.node;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const item = mark?.parent;
|
|
47
|
+
return item && item.name === "ListItem" ? item : null;
|
|
48
|
+
}
|
|
49
|
+
/** pos에서 시작하는 ListMark의 ListItem */
|
|
50
|
+
function itemAtMark(tree, pos) {
|
|
51
|
+
const mark = tree.resolveInner(pos, 1);
|
|
52
|
+
return mark.name === "ListMark" && mark.parent?.name === "ListItem" ? mark.parent : null;
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// 열(column) 계산 — 들여쓰기는 공백 기준입니다. (탭 문자는 한 글자로 취급)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
/** 항목 기호의 열 */
|
|
58
|
+
function markColumn(doc, item) {
|
|
59
|
+
const mark = markOf(item);
|
|
60
|
+
return mark.from - doc.lineAt(mark.from).from;
|
|
61
|
+
}
|
|
62
|
+
/** 항목 내용이 시작하는 열 = 하위 항목이 들여써야 하는 열 (CommonMark 규칙) */
|
|
63
|
+
function contentColumn(doc, item) {
|
|
64
|
+
const mark = markOf(item);
|
|
65
|
+
const line = doc.lineAt(mark.from);
|
|
66
|
+
const text = line.text;
|
|
67
|
+
const markEnd = mark.to - line.from;
|
|
68
|
+
let pos = markEnd;
|
|
69
|
+
while (text.charCodeAt(pos) === 32)
|
|
70
|
+
pos++;
|
|
71
|
+
const spaces = pos - markEnd;
|
|
72
|
+
// 빈 항목이거나 공백이 5개 이상이면 기호 뒤 한 칸이 내용 시작 열입니다.
|
|
73
|
+
if (pos >= text.length || spaces > 4)
|
|
74
|
+
return markEnd + 1;
|
|
75
|
+
return pos;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 줄들을 delta 열만큼 들여쓰거나(+) 내어씁니다(-).
|
|
79
|
+
* - firstLineAt: 첫 줄은 이 위치(항목 기호 앞)에서 변경합니다. ("- - a"처럼 한 줄에 기호가 여러 개인 경우)
|
|
80
|
+
* - 나머지 줄은 앞쪽 접두어(공백, 탭, 인용 '>')와 atColumn 중 작은 위치에서 변경합니다.
|
|
81
|
+
* - 빈 줄은 건드리지 않습니다.
|
|
82
|
+
*/
|
|
83
|
+
function shiftLines(doc, firstLine, lastLine, atColumn, delta, out, firstLineAt) {
|
|
84
|
+
for (let n = firstLine; n <= lastLine; n++) {
|
|
85
|
+
const line = doc.line(n);
|
|
86
|
+
const text = line.text;
|
|
87
|
+
if (!text.trim())
|
|
88
|
+
continue;
|
|
89
|
+
const at = n === firstLine && firstLineAt !== undefined
|
|
90
|
+
? firstLineAt - line.from
|
|
91
|
+
: Math.min(atColumn, /^[ \t>]*/.exec(text)[0].length);
|
|
92
|
+
if (delta > 0) {
|
|
93
|
+
out.push({ from: line.from + at, insert: " ".repeat(delta) });
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
let k = 0;
|
|
97
|
+
while (k < -delta && at - k - 1 >= 0 && text.charCodeAt(at - k - 1) === 32)
|
|
98
|
+
k++;
|
|
99
|
+
if (k)
|
|
100
|
+
out.push({ from: line.from + at - k, to: line.from + at });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** 항목(하위 항목 포함) 전체를 delta 열만큼 이동 */
|
|
105
|
+
function shiftItem(doc, item, delta, out) {
|
|
106
|
+
const mark = markOf(item);
|
|
107
|
+
shiftLines(doc, doc.lineAt(item.from).number, doc.lineAt(item.to).number, markColumn(doc, item), delta, out, mark.from);
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// 선택된 항목
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
/** 선택 범위의 줄 중 기호가 있는 항목들. 다른 선택 항목의 하위 항목은 제외 (문서 순) */
|
|
113
|
+
function selectedItems(state, tree) {
|
|
114
|
+
const { doc } = state;
|
|
115
|
+
const found = new Map();
|
|
116
|
+
for (const range of state.selection.ranges) {
|
|
117
|
+
const first = doc.lineAt(range.from).number;
|
|
118
|
+
let last = doc.lineAt(range.to).number;
|
|
119
|
+
// 다음 줄 맨 앞에서 끝나는 선택은 그 줄을 포함하지 않습니다.
|
|
120
|
+
if (!range.empty && range.to === doc.line(last).from && last > first)
|
|
121
|
+
last--;
|
|
122
|
+
for (let n = first; n <= last; n++) {
|
|
123
|
+
const line = doc.line(n);
|
|
124
|
+
const item = itemOnLine(tree, line.from, line.to);
|
|
125
|
+
if (item)
|
|
126
|
+
found.set(item.from * 1e6 + item.to, item);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const items = [...found.values()];
|
|
130
|
+
const isAncestorSelected = (item) => {
|
|
131
|
+
for (let p = item.parent; p; p = p.parent) {
|
|
132
|
+
if (p.name === "ListItem" && items.some((o) => o.from === p.from && o.to === p.to))
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
};
|
|
137
|
+
return items.filter((item) => !isAncestorSelected(item)).sort((a, b) => a.from - b.from);
|
|
138
|
+
}
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// 번호 다시 매기기
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
const OrderedMarkRe = /^(\d+)([.)])$/;
|
|
143
|
+
/** 목록을 담을 수 있는 컨테이너 노드 */
|
|
144
|
+
const CONTAINERS = new Set([
|
|
145
|
+
"BulletList",
|
|
146
|
+
"OrderedList",
|
|
147
|
+
"ListItem",
|
|
148
|
+
"Blockquote",
|
|
149
|
+
]);
|
|
150
|
+
/** list와 그 조상/자손 OrderedList들의 첫 항목 기호 위치 (변경 전 트리 기준) */
|
|
151
|
+
function firstItemMarks(lists) {
|
|
152
|
+
const out = [];
|
|
153
|
+
const add = (list) => {
|
|
154
|
+
if (list.name !== "OrderedList")
|
|
155
|
+
return;
|
|
156
|
+
const first = itemsOf(list)[0];
|
|
157
|
+
if (first)
|
|
158
|
+
out.push(markOf(first).from);
|
|
159
|
+
};
|
|
160
|
+
const addDescendants = (node) => {
|
|
161
|
+
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
162
|
+
if (!CONTAINERS.has(child.name))
|
|
163
|
+
continue;
|
|
164
|
+
add(child);
|
|
165
|
+
addDescendants(child);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
for (const list of lists) {
|
|
169
|
+
for (let p = list; p; p = p.parent)
|
|
170
|
+
add(p);
|
|
171
|
+
addDescendants(list);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 목록 번호를 순서대로 다시 매깁니다.
|
|
177
|
+
* - 첫 항목이 원래 어떤 번호 목록의 첫 항목이었다면(기존 목록) 시작 번호를 유지합니다.
|
|
178
|
+
* - 그렇지 않으면(새로 생긴 목록) 1부터 시작합니다.
|
|
179
|
+
* - 번호 폭이 바뀌면(9. → 10.) 항목의 이어지는 줄도 같은 만큼 이동합니다.
|
|
180
|
+
*/
|
|
181
|
+
function renumber(doc, list, keepStart, out) {
|
|
182
|
+
const items = itemsOf(list);
|
|
183
|
+
const first = items[0];
|
|
184
|
+
if (!first)
|
|
185
|
+
return;
|
|
186
|
+
const firstMark = markOf(first);
|
|
187
|
+
const firstMatch = OrderedMarkRe.exec(doc.sliceString(firstMark.from, firstMark.to));
|
|
188
|
+
if (!firstMatch)
|
|
189
|
+
return;
|
|
190
|
+
const start = keepStart.has(firstMark.from) ? Number(firstMatch[1]) : 1;
|
|
191
|
+
items.forEach((item, i) => {
|
|
192
|
+
const mark = markOf(item);
|
|
193
|
+
const text = doc.sliceString(mark.from, mark.to);
|
|
194
|
+
const m = OrderedMarkRe.exec(text);
|
|
195
|
+
if (!m)
|
|
196
|
+
return;
|
|
197
|
+
const next = `${start + i}${m[2]}`;
|
|
198
|
+
if (next === text)
|
|
199
|
+
return;
|
|
200
|
+
out.push({ from: mark.from, to: mark.to, insert: next });
|
|
201
|
+
const widthDelta = next.length - text.length;
|
|
202
|
+
const firstLine = doc.lineAt(mark.from).number;
|
|
203
|
+
const lastLine = doc.lineAt(item.to).number;
|
|
204
|
+
if (widthDelta && lastLine > firstLine) {
|
|
205
|
+
shiftLines(doc, firstLine + 1, lastLine, contentColumn(doc, item), widthDelta, out);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* 들여쓰기/내어쓰기 변경을 적용하고, 영향을 받은 번호 목록의 번호를 다시 매겨
|
|
211
|
+
* 하나의 트랜잭션으로 내보냅니다. (실행 취소 한 번에 되돌아갑니다)
|
|
212
|
+
*/
|
|
213
|
+
function commit(state, dispatch, changes, oldLists, userEvent) {
|
|
214
|
+
let all = state.changes(changes);
|
|
215
|
+
let current = state.update({ changes: all }).state;
|
|
216
|
+
// 변경 전 "첫 항목"이었던 기호 위치(변경 후 좌표). 이 항목으로 시작하는 목록은 시작 번호 유지.
|
|
217
|
+
let keepStart = firstItemMarks(oldLists).map((p) => all.mapPos(p, 1));
|
|
218
|
+
// 옮겨진 항목과 형제 항목들의 기호 위치(변경 후 좌표)
|
|
219
|
+
let marks = [];
|
|
220
|
+
for (const list of oldLists) {
|
|
221
|
+
for (const item of itemsOf(list))
|
|
222
|
+
marks.push(all.mapPos(markOf(item).from, 1));
|
|
223
|
+
}
|
|
224
|
+
// 번호 폭이 바뀌면(9. ↔ 10.) 구조가 다시 바뀔 수 있으므로 안정될 때까지 반복합니다.
|
|
225
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
226
|
+
const tree = fullTree(current);
|
|
227
|
+
const lists = new Map();
|
|
228
|
+
for (const pos of marks) {
|
|
229
|
+
const list = itemAtMark(tree, pos)?.parent;
|
|
230
|
+
if (list?.name === "OrderedList")
|
|
231
|
+
lists.set(list.from, list);
|
|
232
|
+
// 부모 목록 쪽 번호도 바뀔 수 있으므로(내어쓰기) 조상 번호 목록도 포함합니다.
|
|
233
|
+
for (let p = list?.parent; p; p = p.parent) {
|
|
234
|
+
if (p.name === "OrderedList")
|
|
235
|
+
lists.set(p.from, p);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const renumbering = [];
|
|
239
|
+
const keep = new Set(keepStart);
|
|
240
|
+
for (const list of lists.values())
|
|
241
|
+
renumber(current.doc, list, keep, renumbering);
|
|
242
|
+
if (!renumbering.length)
|
|
243
|
+
break;
|
|
244
|
+
const step = current.changes(renumbering);
|
|
245
|
+
all = all.compose(step);
|
|
246
|
+
keepStart = keepStart.map((p) => step.mapPos(p, 1));
|
|
247
|
+
marks = marks.map((p) => step.mapPos(p, 1));
|
|
248
|
+
current = current.update({ changes: step }).state;
|
|
249
|
+
}
|
|
250
|
+
dispatch(state.update({ changes: all, scrollIntoView: true, userEvent }));
|
|
251
|
+
}
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// 명령
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
/**
|
|
256
|
+
* 선택한 목록 항목을 바로 앞 항목의 하위 항목으로 들여씁니다. (하위 항목도 함께 이동)
|
|
257
|
+
* - 들여쓰는 폭은 앞 항목의 내용 시작 열에 맞춥니다. (`- ` 2칸, `1. ` 3칸, `10. ` 4칸)
|
|
258
|
+
* - 앞 항목이 없는 첫 항목은 들여쓸 수 없으므로 아무것도 하지 않습니다.
|
|
259
|
+
* - 목록 항목이 아닌 줄이면 false를 반환해 다른 Tab 동작에 넘깁니다.
|
|
260
|
+
*/
|
|
261
|
+
export const indentListItem = ({ state, dispatch }) => {
|
|
262
|
+
const tree = fullTree(state);
|
|
263
|
+
const items = selectedItems(state, tree);
|
|
264
|
+
if (!items.length)
|
|
265
|
+
return false;
|
|
266
|
+
const selected = new Set(items.map((item) => item.from));
|
|
267
|
+
const changes = [];
|
|
268
|
+
const oldLists = new Set();
|
|
269
|
+
for (const item of items) {
|
|
270
|
+
// 연속으로 선택된 형제 항목들은 모두 "선택되지 않은 앞 항목"의 하위 항목이 됩니다.
|
|
271
|
+
let parent = prevItem(item);
|
|
272
|
+
while (parent && selected.has(parent.from))
|
|
273
|
+
parent = prevItem(parent);
|
|
274
|
+
if (!parent)
|
|
275
|
+
continue;
|
|
276
|
+
const delta = contentColumn(state.doc, parent) - markColumn(state.doc, item);
|
|
277
|
+
if (delta <= 0)
|
|
278
|
+
continue;
|
|
279
|
+
shiftItem(state.doc, item, delta, changes);
|
|
280
|
+
oldLists.add(item.parent);
|
|
281
|
+
}
|
|
282
|
+
if (changes.length)
|
|
283
|
+
commit(state, dispatch, changes, oldLists, "input.indent");
|
|
284
|
+
return true;
|
|
285
|
+
};
|
|
286
|
+
/**
|
|
287
|
+
* 선택한 목록 항목을 한 단계 내어씁니다. (부모 항목과 같은 열로, 하위 항목도 함께 이동)
|
|
288
|
+
* - 뒤따르던 형제 항목들은 내어쓴 항목의 하위 항목이 됩니다.
|
|
289
|
+
* - 최상위 항목은 아무것도 하지 않습니다.
|
|
290
|
+
* - 목록 항목이 아닌 줄이면 false를 반환해 다른 Shift-Tab 동작에 넘깁니다.
|
|
291
|
+
*/
|
|
292
|
+
export const outdentListItem = ({ state, dispatch }) => {
|
|
293
|
+
const tree = fullTree(state);
|
|
294
|
+
const items = selectedItems(state, tree);
|
|
295
|
+
if (!items.length)
|
|
296
|
+
return false;
|
|
297
|
+
const { doc } = state;
|
|
298
|
+
const selected = new Set(items.map((item) => item.from));
|
|
299
|
+
const changes = [];
|
|
300
|
+
const oldLists = new Set();
|
|
301
|
+
for (const item of items) {
|
|
302
|
+
const list = item.parent;
|
|
303
|
+
const parent = list.parent;
|
|
304
|
+
if (!parent || parent.name !== "ListItem")
|
|
305
|
+
continue;
|
|
306
|
+
const parentColumn = markColumn(doc, parent);
|
|
307
|
+
const delta = parentColumn - markColumn(doc, item);
|
|
308
|
+
if (delta >= 0)
|
|
309
|
+
continue;
|
|
310
|
+
shiftItem(doc, item, delta, changes);
|
|
311
|
+
oldLists.add(list);
|
|
312
|
+
// 뒤따르던 (선택되지 않은) 형제 항목들을 내어쓴 항목의 새 내용 열에 맞춰 하위 항목이 되게 합니다.
|
|
313
|
+
// (기호 폭이 다르면 열이 어긋나 형제/하위 관계가 뒤바뀔 수 있으므로 명시적으로 정렬)
|
|
314
|
+
const newContent = parentColumn + (contentColumn(doc, item) - markColumn(doc, item));
|
|
315
|
+
for (let sibling = nextItem(item); sibling && !selected.has(sibling.from); sibling = nextItem(sibling)) {
|
|
316
|
+
const align = newContent - markColumn(doc, sibling);
|
|
317
|
+
if (align)
|
|
318
|
+
shiftItem(doc, sibling, align, changes);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (changes.length)
|
|
322
|
+
commit(state, dispatch, changes, oldLists, "delete.dedent");
|
|
323
|
+
return true;
|
|
324
|
+
};
|
|
325
|
+
/** Tab: 목록 항목 들여쓰기, Shift-Tab: 내어쓰기 */
|
|
326
|
+
export const listKeymap = [
|
|
327
|
+
{ key: "Tab", run: indentListItem, shift: outdentListItem },
|
|
328
|
+
];
|
|
329
|
+
/**
|
|
330
|
+
* 목록 항목에서 Tab/Shift-Tab으로 단계를 조절하는 확장입니다. (무한 깊이, 번호 자동 갱신)
|
|
331
|
+
* 목록이 아닌 줄에서는 다른 Tab 바인딩(예: indentWithTab)으로 넘어가도록 우선순위를 높여 둡니다.
|
|
332
|
+
*/
|
|
333
|
+
export function listIndent() {
|
|
334
|
+
return Prec.high(keymap.of(listKeymap));
|
|
335
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { EditorView, ViewPlugin, type DecorationSet, type PluginValue, type ViewUpdate } from "@codemirror/view";
|
|
2
|
+
import type { Extension } from "@codemirror/state";
|
|
3
|
+
/** 선택 영역(커서 포함)이 걸쳐 있는 줄에 붙는 클래스 */
|
|
4
|
+
export declare const activeLineClass = "cm-active";
|
|
5
|
+
export declare class MarkVisionPlugin implements PluginValue {
|
|
6
|
+
decorations: DecorationSet;
|
|
7
|
+
constructor(view: EditorView);
|
|
8
|
+
update(update: ViewUpdate): void;
|
|
9
|
+
process(view: EditorView): DecorationSet;
|
|
10
|
+
}
|
|
11
|
+
/** MarkVisionPlugin을 에디터에 등록하는 ViewPlugin */
|
|
12
|
+
export declare const markVisionPlugin: ViewPlugin<MarkVisionPlugin, undefined>;
|
|
13
|
+
/**
|
|
14
|
+
* MarkVisionPlugin을 활성화하는 확장입니다.
|
|
15
|
+
* 활성 줄에는 `cm-active` 클래스만 붙으며, 별도의 시각 스타일은 적용하지 않습니다.
|
|
16
|
+
*/
|
|
17
|
+
export declare function markVision(): Extension;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Decoration, EditorView, ViewPlugin, } from "@codemirror/view";
|
|
2
|
+
/** 선택 영역(커서 포함)이 걸쳐 있는 줄에 붙는 클래스 */
|
|
3
|
+
export const activeLineClass = "cm-active";
|
|
4
|
+
const activeLineDecoration = Decoration.line({ class: activeLineClass });
|
|
5
|
+
/**
|
|
6
|
+
* 모든 선택 범위가 걸친 줄 중 화면에 보이는 줄에 라인 데코레이션을 만듭니다.
|
|
7
|
+
* - 멀티 커서/멀티 선택을 모두 처리합니다.
|
|
8
|
+
* - 같은 줄은 한 번만 데코레이션합니다.
|
|
9
|
+
* - 큰 선택 영역에서도 비용이 커지지 않도록 visibleRanges로 범위를 제한합니다.
|
|
10
|
+
*/
|
|
11
|
+
function activeLines(view) {
|
|
12
|
+
const { doc, selection } = view.state;
|
|
13
|
+
const lineStarts = new Set();
|
|
14
|
+
for (const range of selection.ranges) {
|
|
15
|
+
for (const visible of view.visibleRanges) {
|
|
16
|
+
const from = Math.max(range.from, visible.from);
|
|
17
|
+
const to = Math.min(range.to, visible.to);
|
|
18
|
+
if (from > to)
|
|
19
|
+
continue;
|
|
20
|
+
for (let pos = from; pos <= to;) {
|
|
21
|
+
const line = doc.lineAt(pos);
|
|
22
|
+
lineStarts.add(line.from);
|
|
23
|
+
pos = line.to + 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [...lineStarts]
|
|
28
|
+
.sort((a, b) => a - b)
|
|
29
|
+
.map((from) => activeLineDecoration.range(from));
|
|
30
|
+
}
|
|
31
|
+
export class MarkVisionPlugin {
|
|
32
|
+
decorations;
|
|
33
|
+
constructor(view) {
|
|
34
|
+
this.decorations = this.process(view);
|
|
35
|
+
}
|
|
36
|
+
update(update) {
|
|
37
|
+
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
|
38
|
+
this.decorations = this.process(update.view);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
process(view) {
|
|
42
|
+
// activeLines()가 이미 정렬된 결과를 반환하므로 sort 플래그는 필요 없습니다.
|
|
43
|
+
return Decoration.set(activeLines(view));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** MarkVisionPlugin을 에디터에 등록하는 ViewPlugin */
|
|
47
|
+
export const markVisionPlugin = ViewPlugin.fromClass(MarkVisionPlugin, {
|
|
48
|
+
decorations: (plugin) => plugin.decorations,
|
|
49
|
+
});
|
|
50
|
+
/**
|
|
51
|
+
* MarkVisionPlugin을 활성화하는 확장입니다.
|
|
52
|
+
* 활성 줄에는 `cm-active` 클래스만 붙으며, 별도의 시각 스타일은 적용하지 않습니다.
|
|
53
|
+
*/
|
|
54
|
+
export function markVision() {
|
|
55
|
+
return markVisionPlugin;
|
|
56
|
+
}
|
package/dist/math.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { EditorState, Extension } from "@codemirror/state";
|
|
2
|
+
import type { SyntaxNode } from "@lezer/common";
|
|
3
|
+
import { type KatexOptions } from "katex";
|
|
4
|
+
export interface MathInfo {
|
|
5
|
+
readonly from: number;
|
|
6
|
+
readonly to: number;
|
|
7
|
+
/** 블록 수식(`$$` 줄)인지. 아니면 문단 안의 인라인 수식 */
|
|
8
|
+
readonly block: boolean;
|
|
9
|
+
/** 디스플레이 모양으로 그릴지 (블록, 또는 문단 안의 `$$…$$`) */
|
|
10
|
+
readonly display: boolean;
|
|
11
|
+
/** TeX 원문 (인용 기호 등 컨테이너 기호 제외) */
|
|
12
|
+
readonly tex: string;
|
|
13
|
+
/** `$`/`$$` 기호 위치 */
|
|
14
|
+
readonly marks: readonly {
|
|
15
|
+
readonly from: number;
|
|
16
|
+
readonly to: number;
|
|
17
|
+
}[];
|
|
18
|
+
/** 원문 구간 (MathText) */
|
|
19
|
+
readonly texts: readonly {
|
|
20
|
+
readonly from: number;
|
|
21
|
+
readonly to: number;
|
|
22
|
+
}[];
|
|
23
|
+
/** 블록 수식이 줄 전체를 차지하는 최상위 블록인지 (그렇다면 블록 위젯으로 그립니다) */
|
|
24
|
+
readonly wholeLines: boolean;
|
|
25
|
+
/** 블록이 걸친 줄 (첫 줄 시작, 마지막 줄 끝) */
|
|
26
|
+
readonly lineFrom: number;
|
|
27
|
+
readonly lineTo: number;
|
|
28
|
+
}
|
|
29
|
+
/** InlineMath / BlockMath 노드에서 수식 정보를 읽습니다. */
|
|
30
|
+
export declare function readMath(state: EditorState, node: SyntaxNode): MathInfo | null;
|
|
31
|
+
export interface MathOptions {
|
|
32
|
+
/**
|
|
33
|
+
* KaTeX 옵션 (예: `{ macros: { "\\RR": "\\mathbb{R}" } }`). `displayMode`, `throwOnError`는 플러그인이 정합니다.
|
|
34
|
+
* `trust: true`는 `\href` 등을 허용하므로 신뢰할 수 있는 문서에서만 쓰세요.
|
|
35
|
+
*/
|
|
36
|
+
katex?: KatexOptions;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* LaTeX 수식(`$…$`, `$$…$$`)을 KaTeX로 그리는 확장입니다. 문법은 `MathSyntax`(extendedMarkdown에 포함).
|
|
40
|
+
* - KaTeX CSS를 불러와야 합니다: `import "katex/dist/katex.min.css"`
|
|
41
|
+
* - 커서가 수식에 닿으면 원문을 보여주고, 블록 수식은 원문 아래에 미리보기를 보여줍니다.
|
|
42
|
+
* - 최상위 블록 수식은 블록 위젯으로, 인용/목록 안의 블록 수식은 접두어 뒤의 인라인 위젯으로 그립니다.
|
|
43
|
+
* - 줄바꿈을 넘는 대체가 필요하므로 StateField로 제공합니다. 렌더링은 화면에 그려질 때만, 결과는 캐시합니다.
|
|
44
|
+
*/
|
|
45
|
+
export declare function math(options?: MathOptions): Extension;
|
package/dist/math.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { syntaxTree } from "@codemirror/language";
|
|
2
|
+
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
|
|
3
|
+
import katex, {} from "katex";
|
|
4
|
+
import { createRevealField } from "./utils/reveal.js";
|
|
5
|
+
/** MathText 하나의 글자 (안에 낀 인용 기호와 그 뒤 공백 한 칸은 뺍니다) */
|
|
6
|
+
function textOf(state, node) {
|
|
7
|
+
const { doc } = state;
|
|
8
|
+
let out = "";
|
|
9
|
+
let pos = node.from;
|
|
10
|
+
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
11
|
+
out += doc.sliceString(pos, child.from);
|
|
12
|
+
pos = child.to;
|
|
13
|
+
if (child.name === "QuoteMark" && pos < node.to && /[ \t]/.test(doc.sliceString(pos, pos + 1)))
|
|
14
|
+
pos++;
|
|
15
|
+
}
|
|
16
|
+
return out + doc.sliceString(pos, node.to);
|
|
17
|
+
}
|
|
18
|
+
/** InlineMath / BlockMath 노드에서 수식 정보를 읽습니다. */
|
|
19
|
+
export function readMath(state, node) {
|
|
20
|
+
const block = node.name === "BlockMath";
|
|
21
|
+
if (!block && node.name !== "InlineMath")
|
|
22
|
+
return null;
|
|
23
|
+
const marks = [];
|
|
24
|
+
const texts = [];
|
|
25
|
+
const parts = [];
|
|
26
|
+
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
27
|
+
if (child.name === "InlineMathMark" || child.name === "BlockMathMark")
|
|
28
|
+
marks.push({ from: child.from, to: child.to });
|
|
29
|
+
else if (child.name === "MathText") {
|
|
30
|
+
texts.push({ from: child.from, to: child.to });
|
|
31
|
+
parts.push(textOf(state, child));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const { doc } = state;
|
|
35
|
+
const first = doc.lineAt(node.from);
|
|
36
|
+
const last = doc.lineAt(node.to);
|
|
37
|
+
return {
|
|
38
|
+
from: node.from,
|
|
39
|
+
to: node.to,
|
|
40
|
+
block,
|
|
41
|
+
display: block || marks[0].to - marks[0].from === 2,
|
|
42
|
+
tex: parts.join("\n").trim(),
|
|
43
|
+
marks,
|
|
44
|
+
texts,
|
|
45
|
+
wholeLines: block &&
|
|
46
|
+
node.parent?.name === "Document" &&
|
|
47
|
+
!first.text.slice(0, node.from - first.from).trim() &&
|
|
48
|
+
!last.text.slice(node.to - last.from).trim(),
|
|
49
|
+
lineFrom: first.from,
|
|
50
|
+
lineTo: last.to,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** 수식이 있을 수 없는 노드 (하위를 훑지 않음) */
|
|
54
|
+
const SKIP = new Set([
|
|
55
|
+
"FencedCode",
|
|
56
|
+
"CodeBlock",
|
|
57
|
+
"HTMLBlock",
|
|
58
|
+
"InlineCode",
|
|
59
|
+
"HTMLTag",
|
|
60
|
+
"Comment",
|
|
61
|
+
"CommentBlock",
|
|
62
|
+
"ProcessingInstructionBlock",
|
|
63
|
+
"URL",
|
|
64
|
+
"LinkMark",
|
|
65
|
+
"HorizontalRule",
|
|
66
|
+
]);
|
|
67
|
+
function scanMath(state) {
|
|
68
|
+
const found = [];
|
|
69
|
+
syntaxTree(state).iterate({
|
|
70
|
+
enter(node) {
|
|
71
|
+
if (node.name === "InlineMath" || node.name === "BlockMath") {
|
|
72
|
+
const info = readMath(state, node.node);
|
|
73
|
+
if (info)
|
|
74
|
+
found.push(info);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (SKIP.has(node.name))
|
|
78
|
+
return false;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// 그리기 (KaTeX, 결과 캐시)
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
const CACHE_LIMIT = 200;
|
|
87
|
+
class MathRenderer {
|
|
88
|
+
cache = new Map();
|
|
89
|
+
options;
|
|
90
|
+
constructor(options) {
|
|
91
|
+
this.options = options;
|
|
92
|
+
}
|
|
93
|
+
/** KaTeX HTML (최근 결과를 캐시). 문법 오류는 KaTeX가 빨간 원문으로 그립니다. */
|
|
94
|
+
html(tex, display) {
|
|
95
|
+
const key = `${display ? "D" : "I"}${tex}`;
|
|
96
|
+
let html = this.cache.get(key);
|
|
97
|
+
if (html !== undefined) {
|
|
98
|
+
this.cache.delete(key);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
try {
|
|
102
|
+
html = katex.renderToString(tex, { ...this.options, displayMode: display, throwOnError: false });
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
// throwOnError: false여도 잘못된 옵션 등으로 던질 수 있습니다.
|
|
106
|
+
const span = document.createElement("span");
|
|
107
|
+
span.className = "cm-math-error";
|
|
108
|
+
span.textContent = error instanceof Error ? error.message : String(error);
|
|
109
|
+
html = span.outerHTML;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
this.cache.set(key, html);
|
|
113
|
+
if (this.cache.size > CACHE_LIMIT)
|
|
114
|
+
this.cache.delete(this.cache.keys().next().value);
|
|
115
|
+
return html;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
class MathWidget extends WidgetType {
|
|
119
|
+
tex;
|
|
120
|
+
kind;
|
|
121
|
+
renderer;
|
|
122
|
+
constructor(tex, kind, renderer) {
|
|
123
|
+
super();
|
|
124
|
+
this.tex = tex;
|
|
125
|
+
this.kind = kind;
|
|
126
|
+
this.renderer = renderer;
|
|
127
|
+
}
|
|
128
|
+
eq(other) {
|
|
129
|
+
return other.tex === this.tex && other.kind === this.kind && other.renderer === this.renderer;
|
|
130
|
+
}
|
|
131
|
+
toDOM() {
|
|
132
|
+
const inline = this.kind === "inline";
|
|
133
|
+
const el = document.createElement(this.kind === "block" ? "div" : "span");
|
|
134
|
+
el.className = `cm-math cm-math-${this.kind}`;
|
|
135
|
+
el.innerHTML = this.renderer.html(this.tex, !inline);
|
|
136
|
+
return el;
|
|
137
|
+
}
|
|
138
|
+
get estimatedHeight() {
|
|
139
|
+
return this.kind === "block" ? 48 : -1;
|
|
140
|
+
}
|
|
141
|
+
// 클릭을 에디터가 처리해 커서가 수식으로 이동하고 원문이 펼쳐지도록 합니다. (미리보기는 제외)
|
|
142
|
+
ignoreEvent(event) {
|
|
143
|
+
return this.kind === "preview" && event.type !== "mousedown";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const markDeco = Decoration.mark({ class: "cm-formatting cm-formatting-math" });
|
|
147
|
+
const sourceDeco = Decoration.mark({ class: "cm-math-source" });
|
|
148
|
+
const blockLineDeco = Decoration.line({ class: "cm-math-block-line" });
|
|
149
|
+
function buildDecorations(items, revealed, state, renderer) {
|
|
150
|
+
const out = [];
|
|
151
|
+
items.forEach((item, i) => {
|
|
152
|
+
const showSource = revealed[i] || !item.tex;
|
|
153
|
+
if (!showSource) {
|
|
154
|
+
if (!item.block) {
|
|
155
|
+
const widget = new MathWidget(item.tex, item.display ? "inline-display" : "inline", renderer);
|
|
156
|
+
out.push(Decoration.replace({ widget }).range(item.from, item.to));
|
|
157
|
+
}
|
|
158
|
+
else if (item.wholeLines) {
|
|
159
|
+
const widget = new MathWidget(item.tex, "block", renderer);
|
|
160
|
+
out.push(Decoration.replace({ widget, block: true }).range(item.lineFrom, item.lineTo));
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// 인용/목록 안: 줄 중간의 블록 위젯은 줄을 쪼개므로 접두어 뒤를 인라인 위젯으로 대체합니다.
|
|
164
|
+
const widget = new MathWidget(item.tex, "inline-display", renderer);
|
|
165
|
+
out.push(Decoration.replace({ widget }).range(item.from, item.to));
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// 원문: 기호는 흐리게, 블록은 줄마다 고정폭 글꼴
|
|
170
|
+
for (const mark of item.marks)
|
|
171
|
+
out.push(markDeco.range(mark.from, mark.to));
|
|
172
|
+
for (const text of item.texts)
|
|
173
|
+
out.push(sourceDeco.range(text.from, text.to));
|
|
174
|
+
if (item.block) {
|
|
175
|
+
for (let pos = item.lineFrom; pos <= item.lineTo;) {
|
|
176
|
+
const line = state.doc.lineAt(pos);
|
|
177
|
+
out.push(blockLineDeco.range(line.from));
|
|
178
|
+
pos = line.to + 1;
|
|
179
|
+
}
|
|
180
|
+
// 편집 중인 블록 아래에 미리보기
|
|
181
|
+
if (item.tex) {
|
|
182
|
+
const widget = new MathWidget(item.tex, "preview", renderer);
|
|
183
|
+
out.push(item.wholeLines
|
|
184
|
+
? Decoration.widget({ widget, block: true, side: 1 }).range(item.lineTo)
|
|
185
|
+
: Decoration.widget({ widget, side: 1 }).range(item.to));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
return Decoration.set(out, true);
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// 테마
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
const mathTheme = EditorView.baseTheme({
|
|
195
|
+
// KaTeX의 .katex-display는 세로 margin(1em)을 줍니다. 위젯 안의 세로 margin은 에디터의 높이 계산을
|
|
196
|
+
// 어긋나게 하므로 없애고, 감싸는 요소의 padding으로 여백을 줍니다.
|
|
197
|
+
".cm-math .katex-display": { margin: "0" },
|
|
198
|
+
".cm-math-block, .cm-math-preview, .cm-math-inline-display": {
|
|
199
|
+
display: "block",
|
|
200
|
+
overflowX: "auto",
|
|
201
|
+
overflowY: "hidden",
|
|
202
|
+
textAlign: "center",
|
|
203
|
+
},
|
|
204
|
+
".cm-math-block": { padding: "0.5em 6px" },
|
|
205
|
+
".cm-math-inline-display": { padding: "0.25em 0" },
|
|
206
|
+
".cm-math-preview": {
|
|
207
|
+
padding: "0.5em 6px",
|
|
208
|
+
borderRadius: "4px",
|
|
209
|
+
cursor: "default",
|
|
210
|
+
},
|
|
211
|
+
"&light .cm-math-preview": { backgroundColor: "rgba(135, 131, 120, 0.08)" },
|
|
212
|
+
"&dark .cm-math-preview": { backgroundColor: "rgba(255, 255, 255, 0.05)" },
|
|
213
|
+
".cm-math-inline": { padding: "0 1px" },
|
|
214
|
+
".cm-math-error": { color: "#cc0000", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
|
|
215
|
+
".cm-formatting-math": { opacity: "0.6" },
|
|
216
|
+
".cm-math-block-line": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
|
|
217
|
+
});
|
|
218
|
+
/**
|
|
219
|
+
* LaTeX 수식(`$…$`, `$$…$$`)을 KaTeX로 그리는 확장입니다. 문법은 `MathSyntax`(extendedMarkdown에 포함).
|
|
220
|
+
* - KaTeX CSS를 불러와야 합니다: `import "katex/dist/katex.min.css"`
|
|
221
|
+
* - 커서가 수식에 닿으면 원문을 보여주고, 블록 수식은 원문 아래에 미리보기를 보여줍니다.
|
|
222
|
+
* - 최상위 블록 수식은 블록 위젯으로, 인용/목록 안의 블록 수식은 접두어 뒤의 인라인 위젯으로 그립니다.
|
|
223
|
+
* - 줄바꿈을 넘는 대체가 필요하므로 StateField로 제공합니다. 렌더링은 화면에 그려질 때만, 결과는 캐시합니다.
|
|
224
|
+
*/
|
|
225
|
+
export function math(options = {}) {
|
|
226
|
+
const renderer = new MathRenderer(options.katex ?? {});
|
|
227
|
+
const field = createRevealField({
|
|
228
|
+
scan: scanMath,
|
|
229
|
+
build: (items, revealed, state) => buildDecorations(items, revealed, state, renderer),
|
|
230
|
+
block: (item) => item.block && item.wholeLines,
|
|
231
|
+
});
|
|
232
|
+
return [field, mathTheme];
|
|
233
|
+
}
|