@moda-editor/core 0.1.1 → 0.1.3
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/dist/conformance.d.ts.map +1 -1
- package/dist/conformance.js +140 -0
- package/dist/conformance.js.map +1 -1
- package/dist/editor/extension.d.ts +4 -2
- package/dist/editor/extension.d.ts.map +1 -1
- package/dist/features/ace.d.ts.map +1 -1
- package/dist/features/ace.js +23 -12
- package/dist/features/ace.js.map +1 -1
- package/dist/features/code-block.d.ts +29 -0
- package/dist/features/code-block.d.ts.map +1 -1
- package/dist/features/code-block.js +551 -36
- package/dist/features/code-block.js.map +1 -1
- package/dist/features/image.d.ts +14 -1
- package/dist/features/image.d.ts.map +1 -1
- package/dist/features/image.js +279 -11
- package/dist/features/image.js.map +1 -1
- package/dist/features/marks.d.ts.map +1 -1
- package/dist/features/marks.js +28 -1
- package/dist/features/marks.js.map +1 -1
- package/dist/features/table.d.ts +16 -3
- package/dist/features/table.d.ts.map +1 -1
- package/dist/features/table.js +295 -8
- package/dist/features/table.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/parse/sanitize.d.ts.map +1 -1
- package/dist/parse/sanitize.js +10 -1
- package/dist/parse/sanitize.js.map +1 -1
- package/dist/presets/highlight.d.ts +17 -0
- package/dist/presets/highlight.d.ts.map +1 -1
- package/dist/presets/highlight.js +51 -0
- package/dist/presets/highlight.js.map +1 -1
- package/dist/state/transaction.d.ts +0 -7
- package/dist/state/transaction.d.ts.map +1 -1
- package/dist/state/transaction.js +30 -1
- package/dist/state/transaction.js.map +1 -1
- package/dist/styles.css +441 -28
- package/dist/view/dom.d.ts.map +1 -1
- package/dist/view/dom.js +62 -22
- package/dist/view/dom.js.map +1 -1
- package/dist/view/input.d.ts.map +1 -1
- package/dist/view/input.js +158 -20
- package/dist/view/input.js.map +1 -1
- package/dist/view/mount.d.ts.map +1 -1
- package/dist/view/mount.js +16 -4
- package/dist/view/mount.js.map +1 -1
- package/dist/view/render.d.ts +7 -1
- package/dist/view/render.d.ts.map +1 -1
- package/dist/view/render.js +5 -1
- package/dist/view/render.js.map +1 -1
- package/dist/view/serialize.d.ts +8 -0
- package/dist/view/serialize.d.ts.map +1 -1
- package/dist/view/serialize.js +19 -0
- package/dist/view/serialize.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { firstTextPath, nodeAt } from "../model/traverse.js";
|
|
2
2
|
import { leafPoint } from "../state/apply.js";
|
|
3
3
|
import { normalizedRange, pointCmp, pointEq, textSelection, } from "../state/selection.js";
|
|
4
|
-
import { highlightCode } from "../presets/highlight.js";
|
|
4
|
+
import { codeLangOptions, highlightCode, } from "../presets/highlight.js";
|
|
5
5
|
import { PATH_ATTR } from "../view/dom.js";
|
|
6
6
|
import { defineFeature } from "./feature.js";
|
|
7
7
|
import { blockRule, blockText, blockTextPoint, caretBlockPath, } from "../commands.js";
|
|
@@ -68,20 +68,403 @@ export const toggleCodeBlock = ({ state, tr, dispatch }) => {
|
|
|
68
68
|
export function setCodeLanguage(language) {
|
|
69
69
|
return ({ state, tr, dispatch }) => {
|
|
70
70
|
const blockPath = caretBlockPath(state);
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
if (!blockPath)
|
|
72
|
+
return false;
|
|
73
|
+
return setCodeLanguageAt(blockPath, language)({
|
|
74
|
+
state,
|
|
75
|
+
tr,
|
|
76
|
+
dispatch,
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 경로로 지정한 코드블록의 언어 — 노드 뷰 크롬 바처럼 캐럿과
|
|
82
|
+
* 무관하게 특정 블록을 바꿀 때 쓴다.
|
|
83
|
+
*/
|
|
84
|
+
export function setCodeLanguageAt(blockPath, language) {
|
|
85
|
+
return ({ state, tr, dispatch }) => {
|
|
86
|
+
const block = nodeAt(state.doc, blockPath);
|
|
87
|
+
if (!block || !state.schema.nodeSpec(block.type)?.code)
|
|
73
88
|
return false;
|
|
74
89
|
tr.setNodeAttr(blockPath, "language", language);
|
|
75
90
|
dispatch();
|
|
76
91
|
return true;
|
|
77
92
|
};
|
|
78
93
|
}
|
|
94
|
+
/** 문자열·주석·정규식을 공백으로 마스킹.
|
|
95
|
+
* 블록 주석·템플릿 리터럴은 줄을 넘나드는 상태 머신으로 처리하고,
|
|
96
|
+
* 템플릿의 `${`/`}`는 `{`/`}`로 남겨 안쪽 식의 들여쓰기도 잡는다. */
|
|
97
|
+
function maskCode(src) {
|
|
98
|
+
const modes = ["code"];
|
|
99
|
+
const out = src.split("");
|
|
100
|
+
const interior = [];
|
|
101
|
+
let lineNo = 0;
|
|
102
|
+
let prevSig = ""; // 정규식 vs 나누기 판별용 — 마지막 유의미 문자
|
|
103
|
+
let rxClass = false;
|
|
104
|
+
for (let i = 0; i < src.length; i++) {
|
|
105
|
+
const c = src[i];
|
|
106
|
+
const n = src[i + 1] ?? "";
|
|
107
|
+
const top = modes[modes.length - 1];
|
|
108
|
+
const blank = () => {
|
|
109
|
+
out[i] = " ";
|
|
110
|
+
};
|
|
111
|
+
if (c === "\n") {
|
|
112
|
+
lineNo++;
|
|
113
|
+
// 다음 줄이 여러 줄 구문의 "본문"에서 시작하는지 기록 —
|
|
114
|
+
// 'line' 주석은 \n에서 끝나고 sq/dq/rx도 줄 끝에서 닫히므로
|
|
115
|
+
// 남는 다중줄 모드(block/tmpl 또는 tmpl 안 code)만 본다
|
|
116
|
+
const next = modes.filter((m) => m === "block" || m === "tmpl").length > 0
|
|
117
|
+
&& !(modes.length > 1 && modes[modes.length - 1] === "code");
|
|
118
|
+
if (next)
|
|
119
|
+
interior[lineNo] = true;
|
|
120
|
+
}
|
|
121
|
+
switch (top) {
|
|
122
|
+
case "line":
|
|
123
|
+
if (c === "\n")
|
|
124
|
+
modes.pop();
|
|
125
|
+
else
|
|
126
|
+
blank();
|
|
127
|
+
break;
|
|
128
|
+
case "block":
|
|
129
|
+
if (c === "*" && n === "/") {
|
|
130
|
+
blank();
|
|
131
|
+
out[++i] = " ";
|
|
132
|
+
modes.pop();
|
|
133
|
+
}
|
|
134
|
+
else if (c !== "\n")
|
|
135
|
+
blank();
|
|
136
|
+
break;
|
|
137
|
+
case "sq":
|
|
138
|
+
case "dq": {
|
|
139
|
+
const q = top === "sq" ? "'" : '"';
|
|
140
|
+
if (c === "\n")
|
|
141
|
+
modes.pop(); // 미닫힘 문자열 — 개행은 유지
|
|
142
|
+
else {
|
|
143
|
+
blank();
|
|
144
|
+
if (c === "\\")
|
|
145
|
+
out[++i] = " ";
|
|
146
|
+
else if (c === q)
|
|
147
|
+
modes.pop();
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case "tmpl":
|
|
152
|
+
if (c === "\\") {
|
|
153
|
+
blank();
|
|
154
|
+
out[++i] = " ";
|
|
155
|
+
}
|
|
156
|
+
else if (c === "`") {
|
|
157
|
+
blank();
|
|
158
|
+
modes.pop();
|
|
159
|
+
}
|
|
160
|
+
else if (c === "$" && n === "{") {
|
|
161
|
+
// ${ 식 — 코드 구간으로 돌아간다. 여는 기호는 { 로 남긴다
|
|
162
|
+
blank();
|
|
163
|
+
out[++i] = "{";
|
|
164
|
+
modes.push("code");
|
|
165
|
+
}
|
|
166
|
+
else if (c !== "\n")
|
|
167
|
+
blank();
|
|
168
|
+
break;
|
|
169
|
+
case "rx":
|
|
170
|
+
if (c === "\n")
|
|
171
|
+
modes.pop(); // 미닫힘 정규식 — 개행은 유지
|
|
172
|
+
else {
|
|
173
|
+
blank();
|
|
174
|
+
if (c === "\\")
|
|
175
|
+
out[++i] = " ";
|
|
176
|
+
else if (c === "[")
|
|
177
|
+
rxClass = true;
|
|
178
|
+
else if (c === "]")
|
|
179
|
+
rxClass = false;
|
|
180
|
+
else if (c === "/" && !rxClass)
|
|
181
|
+
modes.pop();
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
default: {
|
|
185
|
+
// "code" — 템플릿 ${} 안이면 } 가 이 구간을 닫는다
|
|
186
|
+
if (c === "/" && n === "/") {
|
|
187
|
+
blank();
|
|
188
|
+
out[++i] = " ";
|
|
189
|
+
modes.push("line");
|
|
190
|
+
}
|
|
191
|
+
else if (c === "/" && n === "*") {
|
|
192
|
+
blank();
|
|
193
|
+
out[++i] = " ";
|
|
194
|
+
modes.push("block");
|
|
195
|
+
}
|
|
196
|
+
else if (c === "'")
|
|
197
|
+
modes.push("sq");
|
|
198
|
+
else if (c === '"')
|
|
199
|
+
modes.push("dq");
|
|
200
|
+
else if (c === "`")
|
|
201
|
+
modes.push("tmpl");
|
|
202
|
+
else if (c === "/" &&
|
|
203
|
+
"(,=:[!&|?{};+-*%<>\n~^".includes(prevSig || "\n")) {
|
|
204
|
+
modes.push("rx");
|
|
205
|
+
rxClass = false;
|
|
206
|
+
blank();
|
|
207
|
+
}
|
|
208
|
+
else if (c === "}" && modes.length > 1) {
|
|
209
|
+
// 템플릿 ${} 의 닫기 — 식 구간을 빠져나간다
|
|
210
|
+
modes.pop();
|
|
211
|
+
}
|
|
212
|
+
if (!/\s/.test(c))
|
|
213
|
+
prevSig = c;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { text: out.join(""), interior };
|
|
218
|
+
}
|
|
219
|
+
const BRACE_LANGS = new Set([
|
|
220
|
+
"js", "jsx", "ts", "tsx", "javascript", "typescript",
|
|
221
|
+
"java", "c", "cpp", "cs", "go", "rust", "rs", "php",
|
|
222
|
+
"swift", "kotlin", "kt", "css", "scss", "less", "json",
|
|
223
|
+
]);
|
|
224
|
+
const MARKUP_LANGS = new Set(["html", "xml", "vue", "svelte"]);
|
|
225
|
+
const SHELL_LANGS = new Set(["sh", "bash", "shell", "zsh"]);
|
|
226
|
+
/** 들여쓰기 단위 추정 — 탭이 우세하면 탭, 아니면 가장 얕은 기존
|
|
227
|
+
* 들여쓰기 폭(2/4)을 따르고 둘 다 없으면 prettier 관례 2칸 */
|
|
228
|
+
function indentUnit(lines) {
|
|
229
|
+
let tabs = 0;
|
|
230
|
+
let minSpace = 0;
|
|
231
|
+
for (const l of lines) {
|
|
232
|
+
const ws = l.match(/^[ \t]+/)?.[0];
|
|
233
|
+
if (!ws)
|
|
234
|
+
continue;
|
|
235
|
+
if (ws.includes("\t"))
|
|
236
|
+
tabs++;
|
|
237
|
+
else if (!minSpace || ws.length < minSpace)
|
|
238
|
+
minSpace = ws.length;
|
|
239
|
+
}
|
|
240
|
+
if (tabs > 0)
|
|
241
|
+
return "\t";
|
|
242
|
+
if (minSpace >= 3)
|
|
243
|
+
return " ";
|
|
244
|
+
return " ";
|
|
245
|
+
}
|
|
246
|
+
/** 중괄호 계열 재들여쓰기 — 줄 구조는 건드리지 않고 선행 공백만
|
|
247
|
+
* 깊이에 맞게 다시 쓴다. case/default 라벨도 깊이로 모델한다 */
|
|
248
|
+
function reindentBraces(code, unit) {
|
|
249
|
+
const mask = maskCode(code);
|
|
250
|
+
const masked = mask.text.split("\n");
|
|
251
|
+
const lines = code.split("\n");
|
|
252
|
+
const stack = [];
|
|
253
|
+
return lines
|
|
254
|
+
.map((line, i) => {
|
|
255
|
+
const t = (masked[i] ?? "").trim();
|
|
256
|
+
// 여러 줄 문자열/블록 주석/템플릿 본문 — 값이 바뀌므로 원문 유지
|
|
257
|
+
if (mask.interior[i])
|
|
258
|
+
return line;
|
|
259
|
+
if (!t) {
|
|
260
|
+
// 주석 전용 줄은 깊이에 맞춰 재들여쓰기, 진짜 빈 줄은 빈 줄로
|
|
261
|
+
return line.trim() ? unit.repeat(stack.length) + line.trim() : "";
|
|
262
|
+
}
|
|
263
|
+
const leadClosers = t.match(/^[}\])]+/)?.[0].length ?? 0;
|
|
264
|
+
const isCase = /^(case\b[^:]*|default)\s*:/.test(t);
|
|
265
|
+
for (let k = 0; k < leadClosers; k++) {
|
|
266
|
+
// 한 개의 } 는 열린 case 몸통과 그 블록을 함께 닫는다
|
|
267
|
+
if (stack[stack.length - 1] === "case")
|
|
268
|
+
stack.pop();
|
|
269
|
+
if (stack[stack.length - 1] === "brace")
|
|
270
|
+
stack.pop();
|
|
271
|
+
}
|
|
272
|
+
if (isCase && stack[stack.length - 1] === "case")
|
|
273
|
+
stack.pop();
|
|
274
|
+
const indent = stack.length;
|
|
275
|
+
if (isCase)
|
|
276
|
+
stack.push("case");
|
|
277
|
+
for (const ch of t.slice(leadClosers)) {
|
|
278
|
+
if (ch === "{" || ch === "[" || ch === "(")
|
|
279
|
+
stack.push("brace");
|
|
280
|
+
else if (ch === "}" || ch === "]" || ch === ")") {
|
|
281
|
+
if (stack[stack.length - 1] === "case")
|
|
282
|
+
stack.pop();
|
|
283
|
+
if (stack[stack.length - 1] === "brace")
|
|
284
|
+
stack.pop();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return unit.repeat(indent) + line.trim();
|
|
288
|
+
})
|
|
289
|
+
.join("\n");
|
|
290
|
+
}
|
|
291
|
+
const VOID_TAGS = new Set([
|
|
292
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
293
|
+
"link", "meta", "param", "source", "track", "wbr",
|
|
294
|
+
]);
|
|
295
|
+
/** HTML/XML 계열 재들여쓰기 — 태그 열기/닫기만 센다 */
|
|
296
|
+
function reindentTags(code, unit) {
|
|
297
|
+
const masked = code
|
|
298
|
+
.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "))
|
|
299
|
+
.split("\n");
|
|
300
|
+
const lines = code.split("\n");
|
|
301
|
+
let depth = 0;
|
|
302
|
+
return lines
|
|
303
|
+
.map((line, i) => {
|
|
304
|
+
const t = (masked[i] ?? "").trim();
|
|
305
|
+
if (!t)
|
|
306
|
+
return "";
|
|
307
|
+
const leadClose = /^<\//.test(t);
|
|
308
|
+
if (leadClose)
|
|
309
|
+
depth = Math.max(0, depth - 1);
|
|
310
|
+
const indent = depth;
|
|
311
|
+
for (const m of t.matchAll(/<(\/?)([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g)) {
|
|
312
|
+
if (m[1] === "/")
|
|
313
|
+
depth = Math.max(0, depth - 1);
|
|
314
|
+
else if (!m[3].trimEnd().endsWith("/") && !VOID_TAGS.has(m[2].toLowerCase()))
|
|
315
|
+
depth++;
|
|
316
|
+
}
|
|
317
|
+
return unit.repeat(indent) + line.trim();
|
|
318
|
+
})
|
|
319
|
+
.join("\n");
|
|
320
|
+
}
|
|
321
|
+
/** POSIX 쉘 재들여쓰기 — if/fi, for|while/done, case/esac, {} 블록 */
|
|
322
|
+
function reindentShell(code, unit) {
|
|
323
|
+
const lines = code.split("\n");
|
|
324
|
+
let depth = 0;
|
|
325
|
+
return lines
|
|
326
|
+
.map((line) => {
|
|
327
|
+
// 문자열과 # 주석을 지운 스캔용 사본
|
|
328
|
+
const t = line
|
|
329
|
+
.replace(/"(?:[^"\\]|\\.)*"?/g, "")
|
|
330
|
+
.replace(/'(?:[^'\\]|\\.)*'?/g, "")
|
|
331
|
+
.replace(/#[^\n]*/, "")
|
|
332
|
+
.trim();
|
|
333
|
+
if (!t)
|
|
334
|
+
return "";
|
|
335
|
+
// elif/else/then/do 는 이전 몸통을 닫고 다음 몸통을 연다
|
|
336
|
+
const transient = /^(elif|else|then|do|fi|done|esac|\}|\))/.test(t) ? 1 : 0;
|
|
337
|
+
const indent = Math.max(0, depth - transient);
|
|
338
|
+
if (/^(elif|else|then|do)\b/.test(t)) {
|
|
339
|
+
// 같은 깊이에서 계속 — 순수 클로저만 depth를 낮춘다
|
|
340
|
+
}
|
|
341
|
+
else if (/^(fi|done|esac|\}|\))/.test(t)) {
|
|
342
|
+
depth = Math.max(0, depth - 1);
|
|
343
|
+
}
|
|
344
|
+
if (/^(if|for|while|until|select|case)\b/.test(t) || /\b(then|do)\s*$/.test(t) || /\{\s*$/.test(t)) {
|
|
345
|
+
depth++;
|
|
346
|
+
}
|
|
347
|
+
return unit.repeat(indent) + line.trim();
|
|
348
|
+
})
|
|
349
|
+
.join("\n");
|
|
350
|
+
}
|
|
351
|
+
const SQL_KEYWORDS = new Set([
|
|
352
|
+
"select", "from", "where", "group", "by", "having", "order",
|
|
353
|
+
"limit", "offset", "insert", "into", "values", "update", "set",
|
|
354
|
+
"delete", "join", "inner", "left", "right", "full", "outer", "on",
|
|
355
|
+
"as", "and", "or", "not", "null", "is", "in", "exists", "union",
|
|
356
|
+
"all", "distinct", "case", "when", "then", "else", "end",
|
|
357
|
+
"between", "like", "asc", "desc", "create", "table", "drop",
|
|
358
|
+
"alter", "primary", "key", "foreign", "references", "index",
|
|
359
|
+
]);
|
|
360
|
+
/** SQL — 예약어를 대문자로 (문자열/주석 밖만) */
|
|
361
|
+
function upperSqlKeywords(code) {
|
|
362
|
+
// -- 줄 주석도 마스킹에 포함 (maskCode는 C 계열 주석만 안다)
|
|
363
|
+
const masked = maskCode(code).text.replace(/--[^\n]*/g, (m) => " ".repeat(m.length));
|
|
364
|
+
return code.replace(/\b[a-z]+\b/gi, (w, off) => masked[off] !== " " && SQL_KEYWORDS.has(w.toLowerCase())
|
|
365
|
+
? w.toUpperCase()
|
|
366
|
+
: w);
|
|
367
|
+
}
|
|
79
368
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
369
|
+
* 코드 서식 정리 — 언어 서버 없이 안전한 수준만 한다:
|
|
370
|
+
* - 언어가 json이거나(미지정이고 {/[ 로 시작할 때) JSON으로 파싱되면
|
|
371
|
+
* 2칸 pretty-print
|
|
372
|
+
* - 중괄호 계열(js/ts/java/c/go/rust/php/css…): 브래킷 깊이로
|
|
373
|
+
* 재들여쓰기 — 문자열·주석·템플릿·정규식 안의 기호는 세지 않는다
|
|
374
|
+
* - 마크업(html/xml/vue/svelte): 태그 깊이로 재들여쓰기
|
|
375
|
+
* - 쉘(sh/bash): if/fi·do/done·case/esac·{} 블록으로 재들여쓰기
|
|
376
|
+
* - sql: 예약어 대문자화
|
|
377
|
+
* - 모든 언어: 줄 끝 공백 제거 + 3개 이상 연속 빈 줄을 2개로 +
|
|
378
|
+
* 파일 끝 빈 줄 제거 (EditorConfig/prettier 공통 규칙)
|
|
379
|
+
* 들여쓰기 단위는 기존 코드에서 추정하고 없으면 2칸. 줄 구조는 바꾸지
|
|
380
|
+
* 않는다 — 선행 공백만 다시 쓰는 수준이라 깨진 코드에서도 안전하다.
|
|
381
|
+
* 바뀔 게 없으면 null.
|
|
382
|
+
*/
|
|
383
|
+
export function formatCodeText(code, language) {
|
|
384
|
+
let out = code;
|
|
385
|
+
const lang = language.toLowerCase();
|
|
386
|
+
const lead = code.trimStart()[0] ?? "";
|
|
387
|
+
let jsonOk = false;
|
|
388
|
+
if (lang === "json" || (!lang && (lead === "{" || lead === "["))) {
|
|
389
|
+
try {
|
|
390
|
+
out = JSON.stringify(JSON.parse(code), null, 2);
|
|
391
|
+
jsonOk = true;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// 깨진 JSON은 아래 재들여쓰기·공통 정리만 적용한다
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (!jsonOk) {
|
|
398
|
+
const unit = indentUnit(out.split("\n"));
|
|
399
|
+
if (BRACE_LANGS.has(lang) || (!lang && lead === "{")) {
|
|
400
|
+
out = reindentBraces(out, unit);
|
|
401
|
+
}
|
|
402
|
+
else if (MARKUP_LANGS.has(lang)) {
|
|
403
|
+
out = reindentTags(out, unit);
|
|
404
|
+
}
|
|
405
|
+
else if (SHELL_LANGS.has(lang)) {
|
|
406
|
+
out = reindentShell(out, unit);
|
|
407
|
+
}
|
|
408
|
+
else if (lang === "sql") {
|
|
409
|
+
out = upperSqlKeywords(out);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
out = out
|
|
413
|
+
.split("\n")
|
|
414
|
+
.map((l) => l.replace(/[ \t]+$/, ""))
|
|
415
|
+
.join("\n")
|
|
416
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
417
|
+
.replace(/\n+$/, "");
|
|
418
|
+
return out === code ? null : out;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* 경로로 지정한 코드블록 서식 정리 — 노드 뷰 크롬 바처럼 캐럿과
|
|
422
|
+
* 무관하게 특정 블록을 정리할 때 쓴다. 이 블록 안의 셀렉션
|
|
423
|
+
* 엔드포인트는 새 텍스트 길이로 클램프해 보존한다.
|
|
424
|
+
*/
|
|
425
|
+
export function formatCodeAt(blockPath) {
|
|
426
|
+
return ({ state, tr, dispatch }) => {
|
|
427
|
+
const block = nodeAt(state.doc, blockPath);
|
|
428
|
+
if (!block || !state.schema.nodeSpec(block.type)?.code)
|
|
429
|
+
return false;
|
|
430
|
+
const text = (block.content ?? []).map((c) => c.text ?? "").join("");
|
|
431
|
+
const formatted = formatCodeText(text, String(block.attrs?.["language"] ?? ""));
|
|
432
|
+
if (formatted == null)
|
|
433
|
+
return false;
|
|
434
|
+
const next = {
|
|
435
|
+
...block,
|
|
436
|
+
content: [{ type: "text", text: formatted }],
|
|
437
|
+
};
|
|
438
|
+
tr.replaceNode(blockPath, next);
|
|
439
|
+
const sel = state.selection;
|
|
440
|
+
if (sel.type === "text") {
|
|
441
|
+
const fix = (p) => {
|
|
442
|
+
const off = charOffset(block, blockPath, leafPoint(state.doc, p));
|
|
443
|
+
return off == null
|
|
444
|
+
? p
|
|
445
|
+
: charPoint(next, blockPath, Math.min(off, formatted.length));
|
|
446
|
+
};
|
|
447
|
+
tr.setSelection(textSelection(fix(sel.anchor), fix(sel.focus)));
|
|
448
|
+
}
|
|
449
|
+
dispatch();
|
|
450
|
+
return true;
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/** 캐럿 코드블록 서식 정리 — Shift-Alt-F/정리 버튼 (VS Code 관례) */
|
|
454
|
+
export const formatCode = (ctx) => {
|
|
455
|
+
const c = codeContext(ctx.state);
|
|
456
|
+
if (!c)
|
|
457
|
+
return false;
|
|
458
|
+
return formatCodeAt(c.blockPath)(ctx);
|
|
459
|
+
};
|
|
460
|
+
/**
|
|
461
|
+
* 선택과 겹치는 `code: true` 블록별 문자 좌표를 돌려준다.
|
|
462
|
+
* 콜아웃·인용 같은 컨테이너 안의 중첩 코드블록도 찾는다 —
|
|
463
|
+
* 톱레벨 인덱스(loI..hiI)로 먼저 좁히고 그 아래를 임의 깊이로
|
|
464
|
+
* 순회한다. 컨테이너 경계 포인트(전체 선택의 {path:[],offset:k}
|
|
465
|
+
* 등)는 텍스트 리프로 내려 해석하고, 블록 밖으로 나간 엔드포인트는
|
|
466
|
+
* 그 블록의 경계로 클램프한다 — Mod-A 처럼 여러 블록을 걸친
|
|
467
|
+
* 선택에서도 코드 블록 구간만 정확히 잘라낸다.
|
|
85
468
|
*/
|
|
86
469
|
function codeSelectionContexts(state) {
|
|
87
470
|
const sel = state.selection;
|
|
@@ -95,28 +478,53 @@ function codeSelectionContexts(state) {
|
|
|
95
478
|
if (loI == null || hiI == null)
|
|
96
479
|
return [];
|
|
97
480
|
const out = [];
|
|
481
|
+
const visit = (block, blockPath) => {
|
|
482
|
+
if (state.schema.nodeSpec(block.type)?.code) {
|
|
483
|
+
// 선택과 실제로 겹치는 블록만 낸다 — 리프를 부모 레벨의 자식
|
|
484
|
+
// 인덱스로 환산해 비교한다 (pointCmp는 리프와 부모 경계 포인트를
|
|
485
|
+
// 비교하지 못한다 — 경로 길이 차이로 항상 "뒤" 판정).
|
|
486
|
+
// -1: 리프가 블록보다 앞, 0: 블록 서브트리 안, +1: 뒤
|
|
487
|
+
const pl = blockPath.length - 1;
|
|
488
|
+
const last = blockPath[pl];
|
|
489
|
+
const rel = (leaf) => {
|
|
490
|
+
for (let i = 0; i < pl; i++) {
|
|
491
|
+
const li = leaf.path[i];
|
|
492
|
+
if (li !== blockPath[i])
|
|
493
|
+
return (li ?? -1) < blockPath[i] ? -1 : 1;
|
|
494
|
+
}
|
|
495
|
+
const idx = leaf.path[pl];
|
|
496
|
+
if (idx == null)
|
|
497
|
+
return 0;
|
|
498
|
+
return idx < last ? -1 : idx > last ? 1 : 0;
|
|
499
|
+
};
|
|
500
|
+
if (rel(aLeaf) > 0 || rel(fLeaf) < 0)
|
|
501
|
+
return;
|
|
502
|
+
const text = (block.content ?? []).map((c) => c.text ?? "").join("");
|
|
503
|
+
// 블록 밖 엔드포인트는 앞이면 0, 뒤면 끝으로 클램프
|
|
504
|
+
const clamp = (leaf) => {
|
|
505
|
+
const off = charOffset(block, blockPath, leaf);
|
|
506
|
+
if (off != null)
|
|
507
|
+
return off;
|
|
508
|
+
return pointCmp(leaf, { path: blockPath, offset: 0 }) <= 0
|
|
509
|
+
? 0
|
|
510
|
+
: text.length;
|
|
511
|
+
};
|
|
512
|
+
out.push({
|
|
513
|
+
blockPath,
|
|
514
|
+
block,
|
|
515
|
+
text,
|
|
516
|
+
from: clamp(aLeaf),
|
|
517
|
+
to: clamp(fLeaf),
|
|
518
|
+
});
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
for (const [i, child] of (block.content ?? []).entries()) {
|
|
522
|
+
if (child.type !== "text")
|
|
523
|
+
visit(child, [...blockPath, i]);
|
|
524
|
+
}
|
|
525
|
+
};
|
|
98
526
|
for (let i = loI; i <= hiI && i < (state.doc.content?.length ?? 0); i++) {
|
|
99
|
-
|
|
100
|
-
if (!state.schema.nodeSpec(block.type)?.code)
|
|
101
|
-
continue;
|
|
102
|
-
const blockPath = [i];
|
|
103
|
-
const text = (block.content ?? []).map((c) => c.text ?? "").join("");
|
|
104
|
-
// 블록 밖 엔드포인트는 앞이면 0, 뒤면 끝으로 클램프
|
|
105
|
-
const clamp = (leaf) => {
|
|
106
|
-
const off = charOffset(block, blockPath, leaf);
|
|
107
|
-
if (off != null)
|
|
108
|
-
return off;
|
|
109
|
-
return pointCmp(leaf, { path: blockPath, offset: 0 }) <= 0
|
|
110
|
-
? 0
|
|
111
|
-
: text.length;
|
|
112
|
-
};
|
|
113
|
-
out.push({
|
|
114
|
-
blockPath,
|
|
115
|
-
block,
|
|
116
|
-
text,
|
|
117
|
-
from: i === loI ? clamp(aLeaf) : 0,
|
|
118
|
-
to: i === hiI ? clamp(fLeaf) : text.length,
|
|
119
|
-
});
|
|
527
|
+
visit(state.doc.content[i], [i]);
|
|
120
528
|
}
|
|
121
529
|
return out;
|
|
122
530
|
}
|
|
@@ -526,8 +934,10 @@ export const toggleCodeComment = ({ state, tr, dispatch }) => {
|
|
|
526
934
|
return false;
|
|
527
935
|
const selR = normalizedRange(state.selection);
|
|
528
936
|
const remap = (leaf) => {
|
|
529
|
-
|
|
530
|
-
|
|
937
|
+
// 리프가 어느 코드블록 서브트리에 속하는지 — 중첩 블록은
|
|
938
|
+
// 톱레벨 인덱스가 같을 수 있으니 경로 접두어로 판정한다
|
|
939
|
+
const pi = parts.findIndex((p) => leaf.path.length >= p.ctx.blockPath.length &&
|
|
940
|
+
p.ctx.blockPath.every((v, i) => v === leaf.path[i]));
|
|
531
941
|
if (pi < 0)
|
|
532
942
|
return leaf;
|
|
533
943
|
const p = parts[pi];
|
|
@@ -702,9 +1112,107 @@ const CODE_PAIRS = {
|
|
|
702
1112
|
/** 타입오버 대상 — 닫기/인용이 바로 뒤에 있으면 입력 대신 넘어간다 */
|
|
703
1113
|
const CODE_CLOSERS = ")]}'\"`";
|
|
704
1114
|
const isCodeBlock = (state, block) => state.schema.nodeSpec(block.type)?.code === true;
|
|
1115
|
+
const codeUIs = new WeakMap();
|
|
1116
|
+
/** 캐럿이 속한 코드블록의 pre — 코드 캐럿 아니면 null */
|
|
1117
|
+
function activePreEl(el, state) {
|
|
1118
|
+
const ctx = codeContext(state);
|
|
1119
|
+
if (!ctx)
|
|
1120
|
+
return null;
|
|
1121
|
+
const pre = el.querySelector(`pre[${PATH_ATTR}="${ctx.blockPath.join(",")}"]`);
|
|
1122
|
+
// nodeView 안의 코드블록은 컴포넌트 크롬(.me-codehead)이 소유 —
|
|
1123
|
+
// 오버레이와 이중으로 뜨지 않게 둔다
|
|
1124
|
+
if (!pre || pre.closest(".me-node-view"))
|
|
1125
|
+
return null;
|
|
1126
|
+
return { pre, lang: String(ctx.block.attrs?.["language"] ?? "") };
|
|
1127
|
+
}
|
|
1128
|
+
function ensureCodeUI(el, editor) {
|
|
1129
|
+
const hit = codeUIs.get(el);
|
|
1130
|
+
if (hit)
|
|
1131
|
+
return hit;
|
|
1132
|
+
const doc = el.ownerDocument;
|
|
1133
|
+
const box = doc.createElement("div");
|
|
1134
|
+
box.className = "me-codeui";
|
|
1135
|
+
box.hidden = true;
|
|
1136
|
+
const sel = doc.createElement("select");
|
|
1137
|
+
sel.className = "me-lang me-codelang";
|
|
1138
|
+
sel.title = "코드 언어";
|
|
1139
|
+
const mkBtn = (label, act, title) => {
|
|
1140
|
+
const b = doc.createElement("button");
|
|
1141
|
+
b.type = "button";
|
|
1142
|
+
b.className = "me-codebtn";
|
|
1143
|
+
b.setAttribute("data-code-act", act);
|
|
1144
|
+
b.title = title;
|
|
1145
|
+
b.textContent = label;
|
|
1146
|
+
return b;
|
|
1147
|
+
};
|
|
1148
|
+
box.append(sel, mkBtn("정리", "format", "코드 서식 정리 (Alt+Shift+F) — JSON pretty-print·줄 끝 공백·빈 줄 정리"), mkBtn("⧉", "copy", "코드 복사"));
|
|
1149
|
+
(el.parentElement ?? el).appendChild(box);
|
|
1150
|
+
const ui = { box, sel };
|
|
1151
|
+
codeUIs.set(el, ui);
|
|
1152
|
+
// 버튼은 캐럿을 빼앗지 않는다 — select는 기본 동작(드롭다운)이
|
|
1153
|
+
// 필요해 mousedown을 먹지 않는다
|
|
1154
|
+
box.addEventListener("mousedown", (e) => {
|
|
1155
|
+
if (!(e.target instanceof HTMLSelectElement))
|
|
1156
|
+
e.preventDefault();
|
|
1157
|
+
});
|
|
1158
|
+
sel.addEventListener("change", () => {
|
|
1159
|
+
editor.commands["setCodeLanguage"]?.(sel.value);
|
|
1160
|
+
});
|
|
1161
|
+
box.addEventListener("click", (e) => {
|
|
1162
|
+
const t = e.target;
|
|
1163
|
+
if (!(t instanceof Element))
|
|
1164
|
+
return;
|
|
1165
|
+
const act = t.closest("[data-code-act]")?.getAttribute("data-code-act");
|
|
1166
|
+
if (act === "format") {
|
|
1167
|
+
editor.commands["formatCode"]?.();
|
|
1168
|
+
}
|
|
1169
|
+
else if (act === "copy") {
|
|
1170
|
+
const text = codeTextAt(editor.state);
|
|
1171
|
+
if (text != null) {
|
|
1172
|
+
void el.ownerDocument.defaultView?.navigator?.clipboard?.writeText(text);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
// 레이아웃 변화로 위치가 어긋나면 다시 맞춘다
|
|
1177
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
1178
|
+
new ResizeObserver(() => {
|
|
1179
|
+
if (!ui.box.hidden)
|
|
1180
|
+
paintCodeOverlay(el, editor);
|
|
1181
|
+
}).observe(el);
|
|
1182
|
+
}
|
|
1183
|
+
return ui;
|
|
1184
|
+
}
|
|
1185
|
+
/** 캐럿이 코드블록 안이면 바를 pre 우측 상단에 맞춘다 — 아니면 숨긴다 */
|
|
1186
|
+
function paintCodeOverlay(el, editor) {
|
|
1187
|
+
const ui = ensureCodeUI(el, editor);
|
|
1188
|
+
const found = activePreEl(el, editor.state);
|
|
1189
|
+
if (!found) {
|
|
1190
|
+
ui.box.hidden = true;
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
const { pre, lang } = found;
|
|
1194
|
+
const host = ui.box.offsetParent ?? el.parentElement;
|
|
1195
|
+
const hr = host?.getBoundingClientRect();
|
|
1196
|
+
const r = pre.getBoundingClientRect();
|
|
1197
|
+
ui.box.style.left = `${r.right - (hr?.left ?? 0)}px`;
|
|
1198
|
+
ui.box.style.top = `${r.top - (hr?.top ?? 0)}px`;
|
|
1199
|
+
// 셀렉트 옵션 동기화 — 목록에 없는 언어는 원값 옵션을 덧붙인다
|
|
1200
|
+
const opts = codeLangOptions(lang);
|
|
1201
|
+
const cur = [...ui.sel.options].map((o) => o.value);
|
|
1202
|
+
if (cur.length !== opts.length || cur.some((v, i) => v !== opts[i].value)) {
|
|
1203
|
+
ui.sel.replaceChildren(...opts.map((l) => {
|
|
1204
|
+
const o = el.ownerDocument.createElement("option");
|
|
1205
|
+
o.value = l.value;
|
|
1206
|
+
o.textContent = l.label;
|
|
1207
|
+
return o;
|
|
1208
|
+
}));
|
|
1209
|
+
}
|
|
1210
|
+
ui.sel.value = lang;
|
|
1211
|
+
ui.box.hidden = false;
|
|
1212
|
+
}
|
|
705
1213
|
export const codeBlockFeature = defineFeature({
|
|
706
1214
|
name: "codeBlock",
|
|
707
|
-
description: "코드블록 — ``` 입력 규칙, 구문 하이라이트, Tab 들여쓰기·Mod-/ 주석·Alt-↑↓ 줄 이동·화살표 탈출 (Ace 관례)",
|
|
1215
|
+
description: "코드블록 — ``` 입력 규칙, 구문 하이라이트, 언어 셀렉트·서식 정리·복사 크롬(nodeViews 코드 카드 헤더, 미등록 시 .me-codeui 폴백), Tab 들여쓰기·Mod-/ 주석·Alt-↑↓ 줄 이동·화살표 탈출 (Ace 관례)",
|
|
708
1216
|
nodes: {
|
|
709
1217
|
codeBlock: {
|
|
710
1218
|
content: "inline*",
|
|
@@ -733,6 +1241,9 @@ export const codeBlockFeature = defineFeature({
|
|
|
733
1241
|
commands: {
|
|
734
1242
|
toggleCodeBlock,
|
|
735
1243
|
setCodeLanguage: (ctx, lang) => setCodeLanguage(String(lang ?? ""))(ctx),
|
|
1244
|
+
setCodeLanguageAt: (ctx, path, lang) => setCodeLanguageAt(path, String(lang ?? ""))(ctx),
|
|
1245
|
+
formatCode,
|
|
1246
|
+
formatCodeAt: (ctx, path) => formatCodeAt(path)(ctx),
|
|
736
1247
|
toggleCodeComment,
|
|
737
1248
|
moveCodeLines: (ctx, dir) => moveCodeLines(dir === "up" ? "up" : "down")(ctx),
|
|
738
1249
|
duplicateCodeLines,
|
|
@@ -746,6 +1257,8 @@ export const codeBlockFeature = defineFeature({
|
|
|
746
1257
|
ArrowUp: exitCodeArrow("up"),
|
|
747
1258
|
// 코드블록 줄 편집 — Ace/VS Code 관례 (밖이면 폴스루)
|
|
748
1259
|
"Mod-/": toggleCodeComment,
|
|
1260
|
+
// 서식 정리 — VS Code Format Document (Shift+Alt+F) 관례
|
|
1261
|
+
"Alt-Shift-f": formatCode,
|
|
749
1262
|
"Alt-ArrowUp": moveCodeLines("up"),
|
|
750
1263
|
"Alt-ArrowDown": moveCodeLines("down"),
|
|
751
1264
|
"Mod-Shift-d": duplicateCodeLines,
|
|
@@ -918,11 +1431,13 @@ export const codeBlockFeature = defineFeature({
|
|
|
918
1431
|
return false;
|
|
919
1432
|
},
|
|
920
1433
|
/**
|
|
921
|
-
* DOM 오버레이 — 활성 줄 표시(Ace highlightActiveLine)
|
|
922
|
-
* 인접 괄호 쌍 하이라이트(Ace bracket matching)
|
|
923
|
-
*
|
|
1434
|
+
* DOM 오버레이 — 활성 줄 표시(Ace highlightActiveLine)·캐럿
|
|
1435
|
+
* 인접 괄호 쌍 하이라이트(Ace bracket matching)·코드 크롬 바
|
|
1436
|
+
* (.me-codeui — 언어 셀렉트/정리/복사). doc을 만지지 않는다.
|
|
924
1437
|
*/
|
|
925
|
-
paintOverlay(el, state) {
|
|
1438
|
+
paintOverlay(el, state, editor) {
|
|
1439
|
+
// 캐럿이 코드블록 안이면 크롬 바를 pre 우측 상단에 맞춘다
|
|
1440
|
+
paintCodeOverlay(el, editor);
|
|
926
1441
|
// 활성 줄 — DOM 셀렉션 앵커가 있는 .me-cl에만 is-active를 단다
|
|
927
1442
|
const s = el.ownerDocument.getSelection?.();
|
|
928
1443
|
const node = s?.anchorNode ?? null;
|