mdsquare 0.0.0-stage → 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 +206 -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 +114 -0
- package/dist/editor.js +199 -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 +66 -0
- package/dist/extensions.js +79 -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,686 @@
|
|
|
1
|
+
import { Language, LanguageDescription, syntaxTree } from "@codemirror/language";
|
|
2
|
+
import { Facet, StateEffect, StateField } from "@codemirror/state";
|
|
3
|
+
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
|
|
4
|
+
import { TreeFragment } from "@lezer/common";
|
|
5
|
+
import { highlightTree, tagHighlighter, tags } from "@lezer/highlight";
|
|
6
|
+
import { defaultCodeTheme } from "./codeTheme.js";
|
|
7
|
+
import { BLOCK_MARGIN_X } from "./utils/blockMargin.js";
|
|
8
|
+
import { CONTAINER_BOTTOM, CONTAINER_INDENT, CONTAINER_RIGHT } from "./utils/container.js";
|
|
9
|
+
import { createRevealPlugin } from "./utils/reveal.js";
|
|
10
|
+
/** FencedCode 노드에서 언어, 코드 본문, 기호 위치를 읽습니다. */
|
|
11
|
+
export function readFencedCode(state, node) {
|
|
12
|
+
if (node.name !== "FencedCode")
|
|
13
|
+
return null;
|
|
14
|
+
const { doc } = state;
|
|
15
|
+
const marks = [];
|
|
16
|
+
let info = null;
|
|
17
|
+
const pieces = [];
|
|
18
|
+
let code = "";
|
|
19
|
+
let prevEnd = -1;
|
|
20
|
+
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
21
|
+
if (child.name === "CodeMark")
|
|
22
|
+
marks.push(child);
|
|
23
|
+
else if (child.name === "CodeInfo")
|
|
24
|
+
info = child;
|
|
25
|
+
else if (child.name === "CodeText") {
|
|
26
|
+
// 앞 조각과 다른 줄에서 시작하는데 줄바꿈이 없으면(목록 들여쓰기로 나뉜 경우) 줄바꿈을 넣습니다.
|
|
27
|
+
if (prevEnd >= 0 && !code.endsWith("\n") && doc.lineAt(child.from).number > doc.lineAt(prevEnd).number) {
|
|
28
|
+
code += "\n";
|
|
29
|
+
}
|
|
30
|
+
const text = doc.sliceString(child.from, child.to);
|
|
31
|
+
pieces.push({ codeFrom: code.length, docFrom: child.from, length: text.length });
|
|
32
|
+
code += text;
|
|
33
|
+
prevEnd = child.to;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const open = marks[0];
|
|
37
|
+
if (!open)
|
|
38
|
+
return null;
|
|
39
|
+
const close = marks.length > 1 ? marks[marks.length - 1] : null;
|
|
40
|
+
const infoText = info ? doc.sliceString(info.from, info.to).trim() : "";
|
|
41
|
+
return {
|
|
42
|
+
from: node.from,
|
|
43
|
+
to: node.to,
|
|
44
|
+
lang: (infoText.split(/\s+/)[0] ?? "").toLowerCase(),
|
|
45
|
+
code,
|
|
46
|
+
pieces,
|
|
47
|
+
open: { from: open.from, to: open.to },
|
|
48
|
+
close: close && { from: close.from, to: close.to },
|
|
49
|
+
info: info && { from: info.from, to: info.to },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const CACHE_LIMIT = 100;
|
|
53
|
+
const tokenCache = new Map();
|
|
54
|
+
/** 문자열(과 따옴표로 감싼 속성 이름, 예: JSON 키) 표시: 따옴표를 따로 칠할 때 씁니다. */
|
|
55
|
+
const QUOTED = "cm-codeblock-quoted";
|
|
56
|
+
const quotedMarker = tagHighlighter([{ tag: [tags.string, tags.propertyName], class: QUOTED }]);
|
|
57
|
+
const isQuote = (ch) => ch === 34 || ch === 39 || ch === 96; /* " ' ` */
|
|
58
|
+
/**
|
|
59
|
+
* 문자열 구간 앞뒤의 따옴표(`"`, `'`, `` ` ``, 세 겹 따옴표 포함)를 떼어 quote 클래스로 나눕니다.
|
|
60
|
+
* 보간이 있는 문자열은 조각마다 앞이나 뒤에만 따옴표가 있을 수 있습니다.
|
|
61
|
+
*/
|
|
62
|
+
function splitQuotes(code, from, to, cls, quote, out) {
|
|
63
|
+
let start = from;
|
|
64
|
+
let end = to;
|
|
65
|
+
const open = code.charCodeAt(from);
|
|
66
|
+
if (isQuote(open)) {
|
|
67
|
+
while (start < to && start - from < 3 && code.charCodeAt(start) === open)
|
|
68
|
+
start++;
|
|
69
|
+
// 빈 문자열("")처럼 전부 따옴표면 한 번에
|
|
70
|
+
if (start === to) {
|
|
71
|
+
out.push({ from, to, className: quote });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const close = code.charCodeAt(to - 1);
|
|
76
|
+
if (isQuote(close)) {
|
|
77
|
+
while (end > start && to - end < 3 && code.charCodeAt(end - 1) === close)
|
|
78
|
+
end--;
|
|
79
|
+
}
|
|
80
|
+
if (start > from)
|
|
81
|
+
out.push({ from, to: start, className: quote });
|
|
82
|
+
if (end > start && cls)
|
|
83
|
+
out.push({ from: start, to: end, className: cls });
|
|
84
|
+
if (to > end)
|
|
85
|
+
out.push({ from: end, to, className: quote });
|
|
86
|
+
}
|
|
87
|
+
/** 언어별 마지막 파싱 결과: 같은 블록을 고치는 동안 바뀐 부분만 다시 파싱하는 데 씁니다. */
|
|
88
|
+
const lastParse = new WeakMap();
|
|
89
|
+
/**
|
|
90
|
+
* 코드를 파싱합니다. 같은 언어로 마지막에 파싱한 코드와 앞뒤 공통 부분을 비교해 바뀐 구간을 구하고,
|
|
91
|
+
* 이전 트리를 조각(TreeFragment)으로 넘겨 바뀐 부분만 다시 파싱합니다.
|
|
92
|
+
* (2000줄 블록 기준 한 글자 입력: 전체 파싱 약 40ms → 약 1ms)
|
|
93
|
+
*/
|
|
94
|
+
function parseCode(language, code) {
|
|
95
|
+
const prev = lastParse.get(language);
|
|
96
|
+
let fragments;
|
|
97
|
+
if (prev) {
|
|
98
|
+
const a = prev.code;
|
|
99
|
+
const max = Math.min(a.length, code.length);
|
|
100
|
+
let start = 0;
|
|
101
|
+
while (start < max && a.charCodeAt(start) === code.charCodeAt(start))
|
|
102
|
+
start++;
|
|
103
|
+
let endA = a.length;
|
|
104
|
+
let endB = code.length;
|
|
105
|
+
while (endA > start && endB > start && a.charCodeAt(endA - 1) === code.charCodeAt(endB - 1)) {
|
|
106
|
+
endA--;
|
|
107
|
+
endB--;
|
|
108
|
+
}
|
|
109
|
+
fragments = TreeFragment.applyChanges(TreeFragment.addTree(prev.tree), [
|
|
110
|
+
{ fromA: start, toA: endA, fromB: start, toB: endB },
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
const tree = language.parser.parse(code, fragments);
|
|
114
|
+
lastParse.set(language, { code, tree });
|
|
115
|
+
return tree;
|
|
116
|
+
}
|
|
117
|
+
/** Lezer 파서로 코드 전체를 파싱해 테마 클래스를 붙입니다. (최근 결과를 캐시) */
|
|
118
|
+
export function tokenize(language, theme, lang, code) {
|
|
119
|
+
if (!code)
|
|
120
|
+
return [];
|
|
121
|
+
const key = `${theme.id}\u0000${language.name}\u0000${lang}\u0000${code}`;
|
|
122
|
+
const cached = tokenCache.get(key);
|
|
123
|
+
if (cached) {
|
|
124
|
+
// 최근 사용으로 갱신 (Map은 삽입 순서를 유지하므로 다시 넣으면 맨 뒤로)
|
|
125
|
+
tokenCache.delete(key);
|
|
126
|
+
tokenCache.set(key, cached);
|
|
127
|
+
return cached;
|
|
128
|
+
}
|
|
129
|
+
const spans = [];
|
|
130
|
+
const tree = parseCode(language, code);
|
|
131
|
+
const quote = theme.quoteClass?.(lang) ?? null;
|
|
132
|
+
if (quote) {
|
|
133
|
+
highlightTree(tree, [theme.highlighter(lang), quotedMarker], (from, to, classes) => {
|
|
134
|
+
if (!classes.includes(QUOTED))
|
|
135
|
+
return void spans.push({ from, to, className: classes });
|
|
136
|
+
const className = classes.split(" ").filter((c) => c !== QUOTED).join(" ");
|
|
137
|
+
splitQuotes(code, from, to, className, quote, spans);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
highlightTree(tree, theme.highlighter(lang), (from, to, className) => void spans.push({ from, to, className }));
|
|
142
|
+
}
|
|
143
|
+
tokenCache.set(key, spans);
|
|
144
|
+
if (tokenCache.size > CACHE_LIMIT)
|
|
145
|
+
tokenCache.delete(tokenCache.keys().next().value);
|
|
146
|
+
return spans;
|
|
147
|
+
}
|
|
148
|
+
/** 언어를 불러온 뒤 다시 그리게 하는 효과 */
|
|
149
|
+
const codeLanguageLoaded = StateEffect.define();
|
|
150
|
+
/** 에디터별로 불러오는 중인 언어 (같은 언어로 여러 번 다시 그리지 않도록) */
|
|
151
|
+
const pendingLoads = new WeakMap();
|
|
152
|
+
/** 언어를 찾습니다. 아직 불러오지 않은 언어면 불러오기를 시작하고 null */
|
|
153
|
+
function resolveLanguage(languages, lang, view) {
|
|
154
|
+
if (!lang)
|
|
155
|
+
return null;
|
|
156
|
+
const found = typeof languages === "function" ? languages(lang) : LanguageDescription.matchLanguageName(languages, lang, true);
|
|
157
|
+
if (!found)
|
|
158
|
+
return null;
|
|
159
|
+
if (found instanceof Language)
|
|
160
|
+
return found;
|
|
161
|
+
if (found.support)
|
|
162
|
+
return found.support.language;
|
|
163
|
+
let pending = pendingLoads.get(view);
|
|
164
|
+
if (!pending)
|
|
165
|
+
pendingLoads.set(view, (pending = new Set()));
|
|
166
|
+
if (!pending.has(found)) {
|
|
167
|
+
pending.add(found);
|
|
168
|
+
found.load().then(() => {
|
|
169
|
+
pending.delete(found);
|
|
170
|
+
view.dispatch({ effects: codeLanguageLoaded.of(null) });
|
|
171
|
+
}, () => pending.delete(found));
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 다른 확장이 그림 등으로 대신 그리는 코드 블록 언어 (예: tikz()의 "tikz").
|
|
177
|
+
* 이 언어의 블록은 커서가 밖에 있을 때 codeBlock()이 그리지 않고, 커서가 들어오면(원문) 코드 블록으로 그립니다.
|
|
178
|
+
*/
|
|
179
|
+
export const renderedCodeLanguages = Facet.define({
|
|
180
|
+
combine: (values) => new Set(values.flat()),
|
|
181
|
+
});
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// 도구 모음: 언어 + 복사 버튼 (마우스를 올렸을 때 블록 오른쪽 위에 표시)
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
/** 언어 표시 이름 (없는 언어는 원문 그대로) */
|
|
186
|
+
const LANGUAGE_NAMES = {
|
|
187
|
+
js: "JavaScript",
|
|
188
|
+
javascript: "JavaScript",
|
|
189
|
+
jsx: "JSX",
|
|
190
|
+
ts: "TypeScript",
|
|
191
|
+
typescript: "TypeScript",
|
|
192
|
+
tsx: "TSX",
|
|
193
|
+
py: "Python",
|
|
194
|
+
python: "Python",
|
|
195
|
+
sh: "Shell",
|
|
196
|
+
shell: "Shell",
|
|
197
|
+
bash: "Bash",
|
|
198
|
+
zsh: "Zsh",
|
|
199
|
+
json: "JSON",
|
|
200
|
+
yaml: "YAML",
|
|
201
|
+
yml: "YAML",
|
|
202
|
+
html: "HTML",
|
|
203
|
+
xml: "XML",
|
|
204
|
+
css: "CSS",
|
|
205
|
+
scss: "SCSS",
|
|
206
|
+
md: "Markdown",
|
|
207
|
+
markdown: "Markdown",
|
|
208
|
+
rust: "Rust",
|
|
209
|
+
rs: "Rust",
|
|
210
|
+
go: "Go",
|
|
211
|
+
java: "Java",
|
|
212
|
+
kotlin: "Kotlin",
|
|
213
|
+
c: "C",
|
|
214
|
+
cpp: "C++",
|
|
215
|
+
"c++": "C++",
|
|
216
|
+
cs: "C#",
|
|
217
|
+
csharp: "C#",
|
|
218
|
+
rb: "Ruby",
|
|
219
|
+
ruby: "Ruby",
|
|
220
|
+
php: "PHP",
|
|
221
|
+
sql: "SQL",
|
|
222
|
+
swift: "Swift",
|
|
223
|
+
diff: "Diff",
|
|
224
|
+
tex: "LaTeX",
|
|
225
|
+
latex: "LaTeX",
|
|
226
|
+
tikz: "TikZ",
|
|
227
|
+
};
|
|
228
|
+
/** 도구 모음에 보여줄 언어 이름 */
|
|
229
|
+
export const languageLabel = (lang) => (lang ? LANGUAGE_NAMES[lang] ?? lang : "일반 텍스트");
|
|
230
|
+
const COPY_ICON = '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>';
|
|
231
|
+
const CHECK_ICON = '<path d="M20 6 9 17l-5-5"/>';
|
|
232
|
+
function setIcon(el, paths) {
|
|
233
|
+
el.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
234
|
+
}
|
|
235
|
+
class CodeToolbarWidget extends WidgetType {
|
|
236
|
+
lang;
|
|
237
|
+
copyButton;
|
|
238
|
+
constructor(lang, copyButton) {
|
|
239
|
+
super();
|
|
240
|
+
this.lang = lang;
|
|
241
|
+
this.copyButton = copyButton;
|
|
242
|
+
}
|
|
243
|
+
eq(other) {
|
|
244
|
+
return other.lang === this.lang && other.copyButton === this.copyButton;
|
|
245
|
+
}
|
|
246
|
+
toDOM() {
|
|
247
|
+
const toolbar = document.createElement("span");
|
|
248
|
+
toolbar.className = "cm-codeblock-toolbar";
|
|
249
|
+
const lang = toolbar.appendChild(document.createElement("span"));
|
|
250
|
+
lang.className = "cm-codeblock-lang";
|
|
251
|
+
lang.textContent = languageLabel(this.lang);
|
|
252
|
+
if (this.copyButton) {
|
|
253
|
+
toolbar.appendChild(document.createElement("span")).className = "cm-codeblock-sep";
|
|
254
|
+
const copy = toolbar.appendChild(document.createElement("span"));
|
|
255
|
+
copy.className = "cm-codeblock-copy";
|
|
256
|
+
copy.setAttribute("role", "button");
|
|
257
|
+
copy.setAttribute("aria-label", "코드 복사");
|
|
258
|
+
setIcon(copy, COPY_ICON);
|
|
259
|
+
}
|
|
260
|
+
return toolbar;
|
|
261
|
+
}
|
|
262
|
+
ignoreEvent() {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** pos가 속한 펜스 코드 블록 노드 */
|
|
267
|
+
function fencedCodeAt(state, pos) {
|
|
268
|
+
for (const side of [1, -1]) {
|
|
269
|
+
for (let node = syntaxTree(state).resolveInner(pos, side); node; node = node.parent) {
|
|
270
|
+
if (node.name === "FencedCode")
|
|
271
|
+
return node;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
/** 복사 버튼 클릭 → 그 코드 블록의 본문을 클립보드에 복사 */
|
|
277
|
+
const copyHandler = EditorView.domEventHandlers({
|
|
278
|
+
mousedown(event, view) {
|
|
279
|
+
if (event.button !== 0)
|
|
280
|
+
return false;
|
|
281
|
+
const button = event.target?.closest?.(".cm-codeblock-copy");
|
|
282
|
+
if (!button || !view.contentDOM.contains(button))
|
|
283
|
+
return false;
|
|
284
|
+
const node = fencedCodeAt(view.state, view.posAtDOM(button));
|
|
285
|
+
const block = node && readFencedCode(view.state, node);
|
|
286
|
+
if (!block)
|
|
287
|
+
return false;
|
|
288
|
+
event.preventDefault(); // 커서를 옮기지 않고(원문이 펼쳐지지 않고) 복사만 합니다.
|
|
289
|
+
void navigator.clipboard?.writeText(block.code).then(() => {
|
|
290
|
+
setIcon(button, CHECK_ICON);
|
|
291
|
+
button.classList.add("cm-codeblock-copied");
|
|
292
|
+
button.setAttribute("aria-label", "복사됨");
|
|
293
|
+
setTimeout(() => {
|
|
294
|
+
setIcon(button, COPY_ICON);
|
|
295
|
+
button.classList.remove("cm-codeblock-copied");
|
|
296
|
+
button.setAttribute("aria-label", "코드 복사");
|
|
297
|
+
}, 1500);
|
|
298
|
+
}, () => undefined);
|
|
299
|
+
return true;
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
// --- 마우스를 올린 코드 블록 ---
|
|
303
|
+
// 코드 블록은 여러 줄 요소로 이루어져 있어 CSS :hover로는 "블록 위"를 알 수 없으므로,
|
|
304
|
+
// 마우스 위치의 블록을 상태로 기록하고 그 블록의 여는 줄에 클래스를 붙입니다.
|
|
305
|
+
const setHoveredCodeBlock = StateEffect.define();
|
|
306
|
+
/** 마우스가 올라가 있는 코드 블록의 시작 위치 (없으면 null) */
|
|
307
|
+
const hoveredCodeBlock = StateField.define({
|
|
308
|
+
create: () => null,
|
|
309
|
+
update(value, tr) {
|
|
310
|
+
for (const effect of tr.effects)
|
|
311
|
+
if (effect.is(setHoveredCodeBlock))
|
|
312
|
+
return effect.value;
|
|
313
|
+
return value !== null && tr.docChanged ? tr.changes.mapPos(value) : value;
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
const hoverHandler = EditorView.domEventHandlers({
|
|
317
|
+
mousemove(event, view) {
|
|
318
|
+
if (event.buttons !== 0)
|
|
319
|
+
return false; // 드래그로 선택하는 중에는 건드리지 않습니다.
|
|
320
|
+
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
|
|
321
|
+
const hovered = pos === null ? null : fencedCodeAt(view.state, pos)?.from ?? null;
|
|
322
|
+
// 블록이 바뀔 때만 트랜잭션을 보냅니다.
|
|
323
|
+
if (hovered !== view.state.field(hoveredCodeBlock))
|
|
324
|
+
view.dispatch({ effects: setHoveredCodeBlock.of(hovered) });
|
|
325
|
+
return false;
|
|
326
|
+
},
|
|
327
|
+
mouseleave(_event, view) {
|
|
328
|
+
if (view.state.field(hoveredCodeBlock) !== null)
|
|
329
|
+
view.dispatch({ effects: setHoveredCodeBlock.of(null) });
|
|
330
|
+
return false;
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// 플러그인
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
const hidden = Decoration.replace({});
|
|
337
|
+
/**
|
|
338
|
+
* 코드 블록 상자(배경). 줄 자체의 배경은 인용 세로줄/콜아웃 상자가 쓰므로, 줄 안에 절대 위치 요소로 그립니다.
|
|
339
|
+
* 위치는 컨테이너(인용, 콜아웃, 목록)의 내용 시작을 기준으로 CSS에서 계산합니다.
|
|
340
|
+
* 위젯 자신은 크기가 0인 인라인 요소라 커서 위치 계산에 영향을 주지 않습니다.
|
|
341
|
+
*/
|
|
342
|
+
class CodeBackgroundWidget extends WidgetType {
|
|
343
|
+
eq() {
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
toDOM() {
|
|
347
|
+
const anchor = document.createElement("span");
|
|
348
|
+
anchor.className = "cm-codeblock-bg-anchor";
|
|
349
|
+
anchor.setAttribute("aria-hidden", "true");
|
|
350
|
+
anchor.appendChild(document.createElement("span")).className = "cm-codeblock-bg";
|
|
351
|
+
return anchor;
|
|
352
|
+
}
|
|
353
|
+
ignoreEvent() {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const background = Decoration.widget({ widget: new CodeBackgroundWidget(), side: -1 });
|
|
358
|
+
/** 목록 들여쓰기 칸 수별 클래스 (줄 데코레이션의 style은 인용의 style과 겹치면 하나만 남으므로 클래스로) */
|
|
359
|
+
const MAX_INDENT = 40;
|
|
360
|
+
/** 인용 안 코드 블록의 위아래 여백. 에디터에서 `--cm-codeblock-quote-gap`으로 바꿀 수 있습니다. */
|
|
361
|
+
const QUOTE_GAP = "var(--cm-codeblock-quote-gap, 6px)";
|
|
362
|
+
/** pos 앞의 공백을 거슬러 올라간 위치. 인용 기호(`>`) 바로 뒤의 공백 한 칸은 인용이 숨기므로 건너뜁니다. */
|
|
363
|
+
function indentStart(doc, lineFrom, pos) {
|
|
364
|
+
let start = pos;
|
|
365
|
+
while (start > lineFrom && /[ \t]/.test(doc.sliceString(start - 1, start)))
|
|
366
|
+
start--;
|
|
367
|
+
if (start > lineFrom && doc.sliceString(start - 1, start) === ">" && start < pos)
|
|
368
|
+
start++;
|
|
369
|
+
return start;
|
|
370
|
+
}
|
|
371
|
+
const fenceMark = Decoration.mark({ class: "cm-formatting cm-formatting-code-block" });
|
|
372
|
+
const infoMark = Decoration.mark({ class: "cm-codeblock-info" });
|
|
373
|
+
const lineCache = new Map();
|
|
374
|
+
const line = (className) => {
|
|
375
|
+
let deco = lineCache.get(className);
|
|
376
|
+
if (!deco)
|
|
377
|
+
lineCache.set(className, (deco = Decoration.line({ class: className })));
|
|
378
|
+
return deco;
|
|
379
|
+
};
|
|
380
|
+
const tokenCacheDeco = new Map();
|
|
381
|
+
const tokenMark = (className) => {
|
|
382
|
+
let deco = tokenCacheDeco.get(className);
|
|
383
|
+
if (!deco)
|
|
384
|
+
tokenCacheDeco.set(className, (deco = Decoration.mark({ class: className })));
|
|
385
|
+
return deco;
|
|
386
|
+
};
|
|
387
|
+
/** 코드 블록을 담을 수 있는 컨테이너 블록 */
|
|
388
|
+
const CONTAINERS = new Set([
|
|
389
|
+
"Document",
|
|
390
|
+
"Blockquote",
|
|
391
|
+
"BulletList",
|
|
392
|
+
"OrderedList",
|
|
393
|
+
"ListItem",
|
|
394
|
+
]);
|
|
395
|
+
function codeBlockPlugin(languages, theme, copyButton) {
|
|
396
|
+
return createRevealPlugin({
|
|
397
|
+
// 줄/기호/토큰 데코레이션이 섞이므로 정렬합니다.
|
|
398
|
+
sort: true,
|
|
399
|
+
// 마우스를 올린 블록이 바뀌거나 언어를 불러오면 다시 그립니다.
|
|
400
|
+
rebuildOn: (update) => update.transactions.some((tr) => tr.effects.some((e) => e.is(setHoveredCodeBlock) || e.is(codeLanguageLoaded))),
|
|
401
|
+
visit({ state, view, decorations, isRevealed }) {
|
|
402
|
+
const { doc } = state;
|
|
403
|
+
const hovered = state.field(hoveredCodeBlock, false) ?? null;
|
|
404
|
+
const rendered = state.facet(renderedCodeLanguages);
|
|
405
|
+
const visible = view.visibleRanges;
|
|
406
|
+
const isVisible = (from, to) => visible.some((r) => from < r.to && to > r.from);
|
|
407
|
+
return {
|
|
408
|
+
enter(node) {
|
|
409
|
+
if (node.name === "FencedCode") {
|
|
410
|
+
const block = readFencedCode(state, node.node);
|
|
411
|
+
if (!block)
|
|
412
|
+
return false;
|
|
413
|
+
const revealed = isRevealed(block.from, block.to);
|
|
414
|
+
// 그림으로 그려지는 블록(예: tikz)은 원문을 볼 때만 코드 블록으로 그립니다.
|
|
415
|
+
if (!revealed && rendered.has(block.lang))
|
|
416
|
+
return false;
|
|
417
|
+
// 줄: 배경, 둥근 모서리, 기호 줄 표시
|
|
418
|
+
const first = doc.lineAt(block.from).number;
|
|
419
|
+
const last = doc.lineAt(block.to).number;
|
|
420
|
+
const closeLine = block.close ? doc.lineAt(block.close.from).number : -1;
|
|
421
|
+
// 목록 안: 줄 앞의 목록 들여쓰기(컨테이너 들여쓰기)를 숨기고, 같은 폭을 여백으로 줍니다.
|
|
422
|
+
// (공백 글자로 두면 줄바꿈된 다음 줄이 상자 왼쪽 밖에서 시작합니다)
|
|
423
|
+
let inList = false;
|
|
424
|
+
let inQuote = false; // 인용(콜아웃 포함) 안이면 위아래 여백
|
|
425
|
+
for (let p = node.node.parent; p; p = p.parent) {
|
|
426
|
+
if (p.name === "ListItem")
|
|
427
|
+
inList = true;
|
|
428
|
+
else if (p.name === "Blockquote")
|
|
429
|
+
inQuote = true;
|
|
430
|
+
}
|
|
431
|
+
let indent = 0;
|
|
432
|
+
const contentStart = new Map();
|
|
433
|
+
if (inList) {
|
|
434
|
+
for (let c = node.node.firstChild; c; c = c.nextSibling) {
|
|
435
|
+
if (c.name !== "CodeText" && c.name !== "CodeMark")
|
|
436
|
+
continue;
|
|
437
|
+
const n = doc.lineAt(c.from).number;
|
|
438
|
+
if (!contentStart.has(n))
|
|
439
|
+
contentStart.set(n, c.from);
|
|
440
|
+
}
|
|
441
|
+
const openLine = doc.lineAt(block.open.from);
|
|
442
|
+
indent = Math.min(MAX_INDENT, block.open.from - indentStart(doc, openLine.from, block.open.from));
|
|
443
|
+
}
|
|
444
|
+
for (let n = first; n <= last; n++) {
|
|
445
|
+
const lineObj = doc.line(n);
|
|
446
|
+
decorations.push(background.range(lineObj.from));
|
|
447
|
+
if (indent > 0) {
|
|
448
|
+
const content = contentStart.get(n) ?? lineObj.to;
|
|
449
|
+
const from = indentStart(doc, lineObj.from, content);
|
|
450
|
+
const to = Math.min(content, from + indent);
|
|
451
|
+
if (from < to)
|
|
452
|
+
decorations.push(hidden.range(from, to));
|
|
453
|
+
}
|
|
454
|
+
const cls = ["cm-codeblock-line"];
|
|
455
|
+
if (indent > 0)
|
|
456
|
+
cls.push(`cm-codeblock-indent-${indent}`);
|
|
457
|
+
if (inQuote)
|
|
458
|
+
cls.push("cm-codeblock-in-quote");
|
|
459
|
+
if (n === first)
|
|
460
|
+
cls.push("cm-codeblock-first", "cm-codeblock-open");
|
|
461
|
+
if (n === first && hovered === block.from)
|
|
462
|
+
cls.push("cm-codeblock-hovered");
|
|
463
|
+
if (n === last)
|
|
464
|
+
cls.push("cm-codeblock-last");
|
|
465
|
+
if (n === closeLine)
|
|
466
|
+
cls.push("cm-codeblock-close");
|
|
467
|
+
if (!revealed && (n === first || n === closeLine))
|
|
468
|
+
cls.push("cm-codeblock-fence-hidden");
|
|
469
|
+
decorations.push(line(cls.join(" ")).range(doc.line(n).from));
|
|
470
|
+
}
|
|
471
|
+
// 기호: 커서가 블록 안이면 원문(흐리게), 밖이면 숨김.
|
|
472
|
+
// 도구 모음(언어/복사)은 두 경우 모두 여는 줄에 두고, 블록 오른쪽 위에 겹쳐 그립니다.
|
|
473
|
+
const openLine = doc.lineAt(block.open.from);
|
|
474
|
+
const toolbar = new CodeToolbarWidget(block.lang, copyButton);
|
|
475
|
+
if (revealed) {
|
|
476
|
+
decorations.push(fenceMark.range(block.open.from, block.open.to));
|
|
477
|
+
if (block.info)
|
|
478
|
+
decorations.push(infoMark.range(block.info.from, block.info.to));
|
|
479
|
+
if (block.close)
|
|
480
|
+
decorations.push(fenceMark.range(block.close.from, block.close.to));
|
|
481
|
+
decorations.push(Decoration.widget({ widget: toolbar, side: 1 }).range(openLine.to));
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
decorations.push(Decoration.replace({ widget: toolbar }).range(block.open.from, openLine.to));
|
|
485
|
+
if (block.close) {
|
|
486
|
+
decorations.push(hidden.range(block.close.from, doc.lineAt(block.close.from).to));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// 토큰: 코드 문자열 위치 → 문서 위치로 바꿔, 보이는 범위에만 추가
|
|
490
|
+
const language = resolveLanguage(languages, block.lang, view);
|
|
491
|
+
const spans = language ? tokenize(language, theme, block.lang, block.code) : [];
|
|
492
|
+
for (const span of spans) {
|
|
493
|
+
for (const piece of block.pieces) {
|
|
494
|
+
const from = Math.max(span.from, piece.codeFrom);
|
|
495
|
+
const to = Math.min(span.to, piece.codeFrom + piece.length);
|
|
496
|
+
if (from >= to)
|
|
497
|
+
continue;
|
|
498
|
+
const docFrom = piece.docFrom + (from - piece.codeFrom);
|
|
499
|
+
const docTo = piece.docFrom + (to - piece.codeFrom);
|
|
500
|
+
if (isVisible(docFrom, docTo))
|
|
501
|
+
decorations.push(tokenMark(span.className).range(docFrom, docTo));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
if (!CONTAINERS.has(node.name))
|
|
507
|
+
return false;
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// 테마
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
const baseTheme = EditorView.baseTheme({
|
|
517
|
+
// 줄: 컨테이너 들여쓰기 + 목록 들여쓰기 + 상자 바깥 여백 + 상자 안쪽 여백(12px).
|
|
518
|
+
// 인용/콜아웃의 padding보다 우선하도록 선택자를 구체적으로 씁니다.
|
|
519
|
+
".cm-line.cm-codeblock-line": {
|
|
520
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
521
|
+
position: "relative",
|
|
522
|
+
zIndex: "0", // 상자(z-index: -1)를 줄 배경(인용 세로줄, 콜아웃 상자) 위, 글자 뒤에 그리기 위한 쌓임 맥락
|
|
523
|
+
paddingLeft: `calc(${CONTAINER_INDENT} + var(--cm-codeblock-indent, 0) * 1ch + ${BLOCK_MARGIN_X} + 6px)`,
|
|
524
|
+
paddingRight: `calc(${CONTAINER_RIGHT} + ${BLOCK_MARGIN_X} + 10px)`,
|
|
525
|
+
},
|
|
526
|
+
// 줄마다 쌓임 맥락이 생기므로, 여는 줄의 도구 모음이 아래 줄들에 가려지지 않도록 여는 줄을 한 단계 위에 둡니다.
|
|
527
|
+
".cm-line.cm-codeblock-open": { zIndex: "1" },
|
|
528
|
+
".cm-codeblock-bg-anchor": { display: "inline" },
|
|
529
|
+
".cm-codeblock-bg": {
|
|
530
|
+
position: "absolute",
|
|
531
|
+
top: "0",
|
|
532
|
+
bottom: CONTAINER_BOTTOM, // 콜아웃의 끝 줄이면 상자 아래 여백은 덮지 않음
|
|
533
|
+
// 컨테이너 내용 시작 기준. (최상위: 6px + 8px - 6px = 내용 영역에서 8px)
|
|
534
|
+
left: `calc(${CONTAINER_INDENT} + var(--cm-codeblock-indent, 0) * 1ch + ${BLOCK_MARGIN_X} - 6px)`,
|
|
535
|
+
right: `calc(${CONTAINER_RIGHT} + ${BLOCK_MARGIN_X} - 2px)`,
|
|
536
|
+
zIndex: "-1",
|
|
537
|
+
pointerEvents: "none",
|
|
538
|
+
backgroundColor: "var(--cm-codeblock-bg, transparent)",
|
|
539
|
+
},
|
|
540
|
+
".cm-codeblock-first .cm-codeblock-bg": { borderTopLeftRadius: "4px", borderTopRightRadius: "4px" },
|
|
541
|
+
// 인용(콜아웃 포함) 안: 위아래 글과 떨어지도록 첫/끝 줄에 padding을 주고 상자를 그만큼 안으로 들입니다.
|
|
542
|
+
// (세로 margin은 줄 높이 계산을 깨뜨리므로 쓰지 않습니다. 인용 세로줄은 여백에도 이어집니다)
|
|
543
|
+
".cm-line.cm-codeblock-in-quote.cm-codeblock-first": { paddingTop: QUOTE_GAP },
|
|
544
|
+
".cm-codeblock-in-quote.cm-codeblock-first .cm-codeblock-bg": { top: QUOTE_GAP },
|
|
545
|
+
".cm-codeblock-in-quote.cm-codeblock-open .cm-codeblock-toolbar": { top: `calc(${QUOTE_GAP} + 6px)` },
|
|
546
|
+
".cm-line.cm-codeblock-in-quote.cm-codeblock-last": { paddingBottom: `calc(${CONTAINER_BOTTOM} + ${QUOTE_GAP})` },
|
|
547
|
+
".cm-codeblock-in-quote.cm-codeblock-last .cm-codeblock-bg": { bottom: `calc(${CONTAINER_BOTTOM} + ${QUOTE_GAP})` },
|
|
548
|
+
".cm-codeblock-last .cm-codeblock-bg": { borderBottomLeftRadius: "4px", borderBottomRightRadius: "4px" },
|
|
549
|
+
...Object.fromEntries(Array.from({ length: MAX_INDENT }, (_, i) => [`.cm-codeblock-indent-${i + 1}`, { "--cm-codeblock-indent": String(i + 1) }])),
|
|
550
|
+
// 커서가 밖에 있을 때 펜스 줄은 낮게 (도구 모음은 여는 줄 기준으로 겹쳐 그립니다)
|
|
551
|
+
".cm-codeblock-fence-hidden": { fontSize: "0.5em" },
|
|
552
|
+
// 펜스 줄은 글자 크기가 절반이라 1ch(목록 들여쓰기 폭)도 달라지므로, 상자만 원래 크기로 되돌려 다른 줄과 맞춥니다.
|
|
553
|
+
// (글자 크기를 절반으로 줄인 글꼴의 폭은 정확히 절반이 아니어서 칸 수를 두 배로 세는 방식은 어긋납니다)
|
|
554
|
+
".cm-codeblock-fence-hidden .cm-codeblock-bg": { fontSize: "200%" },
|
|
555
|
+
".cm-formatting-code-block, .cm-codeblock-info": { opacity: "0.5" },
|
|
556
|
+
// 펜스 원문은 코드 블록 글자색으로 (마크다운 기본 강조가 안쪽 span에 붙이는 색은 무시. 어두운 배경에서 안 보임)
|
|
557
|
+
".cm-codeblock-line .cm-formatting-code-block, .cm-codeblock-line .cm-formatting-code-block *, .cm-codeblock-info, .cm-codeblock-info *": { color: "inherit" },
|
|
558
|
+
// 도구 모음: 블록 오른쪽 위, 마우스를 올렸을 때만
|
|
559
|
+
".cm-codeblock-toolbar": {
|
|
560
|
+
position: "absolute",
|
|
561
|
+
top: "6px",
|
|
562
|
+
// 상자 오른쪽 끝에서 8px 안쪽
|
|
563
|
+
right: `calc(${CONTAINER_RIGHT} + ${BLOCK_MARGIN_X} + 6px)`,
|
|
564
|
+
zIndex: "2",
|
|
565
|
+
display: "inline-flex",
|
|
566
|
+
alignItems: "center",
|
|
567
|
+
gap: "6px",
|
|
568
|
+
padding: "3px 8px",
|
|
569
|
+
borderRadius: "6px",
|
|
570
|
+
border: "1px solid",
|
|
571
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
572
|
+
fontSize: "12px",
|
|
573
|
+
lineHeight: "18px",
|
|
574
|
+
opacity: "0",
|
|
575
|
+
pointerEvents: "none",
|
|
576
|
+
transition: "opacity 120ms ease",
|
|
577
|
+
userSelect: "none",
|
|
578
|
+
},
|
|
579
|
+
"&light .cm-codeblock-toolbar": {
|
|
580
|
+
backgroundColor: "#ffffff",
|
|
581
|
+
borderColor: "rgba(0, 0, 0, 0.08)",
|
|
582
|
+
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)",
|
|
583
|
+
color: "#6b7280",
|
|
584
|
+
},
|
|
585
|
+
"&dark .cm-codeblock-toolbar": {
|
|
586
|
+
backgroundColor: "#1f2937",
|
|
587
|
+
borderColor: "rgba(255, 255, 255, 0.1)",
|
|
588
|
+
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.4)",
|
|
589
|
+
color: "#9ca3af",
|
|
590
|
+
},
|
|
591
|
+
".cm-codeblock-hovered .cm-codeblock-toolbar": { opacity: "1", pointerEvents: "auto" },
|
|
592
|
+
".cm-codeblock-sep": { width: "1px", alignSelf: "stretch", backgroundColor: "currentColor", opacity: "0.3" },
|
|
593
|
+
".cm-codeblock-copy": {
|
|
594
|
+
display: "inline-flex",
|
|
595
|
+
width: "14px",
|
|
596
|
+
height: "14px",
|
|
597
|
+
cursor: "pointer",
|
|
598
|
+
},
|
|
599
|
+
".cm-codeblock-copy svg": { width: "100%", height: "100%" },
|
|
600
|
+
".cm-codeblock-copy:hover": { color: "#374151" },
|
|
601
|
+
"&dark .cm-codeblock-copy:hover": { color: "#e5e7eb" },
|
|
602
|
+
".cm-codeblock-copied": { color: "#16a34a !important" },
|
|
603
|
+
"@media (prefers-reduced-motion: reduce)": {
|
|
604
|
+
".cm-codeblock-toolbar": { transition: "none" },
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
// 선택 영역과 커서
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
// drawSelection의 선택 영역은 줄 뒤에 그려지므로, 테마가 코드 블록에 불투명한 배경을 칠하면 가려집니다.
|
|
611
|
+
// 코드 블록 안의 선택 부분에 클래스를 붙여 테마가 그 위에 선택 색을 칠할 수 있게 합니다.
|
|
612
|
+
const selectedMark = Decoration.mark({ class: "cm-codeblock-selected" });
|
|
613
|
+
function buildSelection(view) {
|
|
614
|
+
const { state } = view;
|
|
615
|
+
const ranges = [];
|
|
616
|
+
for (const range of state.selection.ranges) {
|
|
617
|
+
if (range.empty)
|
|
618
|
+
continue;
|
|
619
|
+
for (const visible of view.visibleRanges) {
|
|
620
|
+
const from = Math.max(range.from, visible.from);
|
|
621
|
+
const to = Math.min(range.to, visible.to);
|
|
622
|
+
if (from >= to)
|
|
623
|
+
continue;
|
|
624
|
+
syntaxTree(state).iterate({
|
|
625
|
+
from,
|
|
626
|
+
to,
|
|
627
|
+
enter(node) {
|
|
628
|
+
if (node.name === "FencedCode") {
|
|
629
|
+
const start = Math.max(from, node.from);
|
|
630
|
+
const end = Math.min(to, node.to);
|
|
631
|
+
if (start < end)
|
|
632
|
+
ranges.push(selectedMark.range(start, end));
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
if (!CONTAINERS.has(node.name))
|
|
636
|
+
return false;
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return ranges.length ? Decoration.set(ranges, true) : Decoration.none;
|
|
642
|
+
}
|
|
643
|
+
const codeSelection = ViewPlugin.fromClass(class {
|
|
644
|
+
decorations;
|
|
645
|
+
constructor(view) {
|
|
646
|
+
this.decorations = buildSelection(view);
|
|
647
|
+
}
|
|
648
|
+
update(update) {
|
|
649
|
+
if (update.selectionSet ||
|
|
650
|
+
update.docChanged ||
|
|
651
|
+
update.viewportChanged ||
|
|
652
|
+
syntaxTree(update.startState) !== syntaxTree(update.state)) {
|
|
653
|
+
// 선택이 없으면(가장 흔한 경우) 트리를 훑지 않습니다.
|
|
654
|
+
this.decorations = update.state.selection.ranges.some((r) => !r.empty)
|
|
655
|
+
? buildSelection(update.view)
|
|
656
|
+
: Decoration.none;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}, { decorations: (plugin) => plugin.decorations });
|
|
660
|
+
/** 커서가 코드 블록 안이면 에디터에 클래스를 붙입니다. (테마가 커서 색을 바꿀 수 있도록) */
|
|
661
|
+
const cursorInCode = EditorView.editorAttributes.of((view) => fencedCodeAt(view.state, view.state.selection.main.head) ? { class: "cm-codeblock-cursor" } : null);
|
|
662
|
+
/**
|
|
663
|
+
* 펜스 코드 블록(```` ```lang ````)을 CodeMirror(Lezer) 언어 파서로 강조해 보여주는 확장입니다.
|
|
664
|
+
* - 언어: 정보 문자열의 첫 단어(별칭 포함, 예: ts, js, py). `languages`에서 찾고, 처음 쓰일 때 불러옵니다.
|
|
665
|
+
* ```ts
|
|
666
|
+
* import { languages } from "@codemirror/language-data";
|
|
667
|
+
* codeBlock({ languages, theme: vscodeTheme(themeJsonText) });
|
|
668
|
+
* ```
|
|
669
|
+
* - 코드 블록마다 따로 파싱하므로 문서 전체 구문 트리에 다른 언어가 섞이지 않습니다.
|
|
670
|
+
* lang-markdown의 `codeLanguages`와 함께 쓰면 두 강조가 겹치므로 함께 쓰지 마세요.
|
|
671
|
+
* - 커서가 블록 밖이면 펜스 줄을 숨기고, 안이면 펜스 원문을 보여줍니다.
|
|
672
|
+
* - 블록에 마우스를 올리면 오른쪽 위에 도구 모음(언어, 복사 버튼)을 보여줍니다.
|
|
673
|
+
*/
|
|
674
|
+
export function codeBlock(options = {}) {
|
|
675
|
+
const { languages = [], theme = defaultCodeTheme, copyButton = true } = options;
|
|
676
|
+
return [
|
|
677
|
+
hoveredCodeBlock,
|
|
678
|
+
codeBlockPlugin(languages, theme, copyButton),
|
|
679
|
+
codeSelection,
|
|
680
|
+
cursorInCode,
|
|
681
|
+
hoverHandler,
|
|
682
|
+
copyHandler,
|
|
683
|
+
baseTheme,
|
|
684
|
+
theme.extension,
|
|
685
|
+
];
|
|
686
|
+
}
|