@moda-editor/core 0.1.3 → 0.1.5
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/collab.d.ts +61 -0
- package/dist/collab.d.ts.map +1 -0
- package/dist/collab.js +67 -0
- package/dist/collab.js.map +1 -0
- package/dist/commands.d.ts +11 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +48 -4
- package/dist/commands.js.map +1 -1
- package/dist/conformance/editor-base.d.ts +7 -0
- package/dist/conformance/editor-base.d.ts.map +1 -0
- package/dist/conformance/editor-base.js +280 -0
- package/dist/conformance/editor-base.js.map +1 -0
- package/dist/conformance/editor-codeblock.d.ts +8 -0
- package/dist/conformance/editor-codeblock.d.ts.map +1 -0
- package/dist/conformance/editor-codeblock.js +402 -0
- package/dist/conformance/editor-codeblock.js.map +1 -0
- package/dist/conformance/editor-ime.d.ts +7 -0
- package/dist/conformance/editor-ime.d.ts.map +1 -0
- package/dist/conformance/editor-ime.js +166 -0
- package/dist/conformance/editor-ime.js.map +1 -0
- package/dist/conformance/editor-input.d.ts +8 -0
- package/dist/conformance/editor-input.d.ts.map +1 -0
- package/dist/conformance/editor-input.js +676 -0
- package/dist/conformance/editor-input.js.map +1 -0
- package/dist/conformance/editor-nodeview.d.ts +7 -0
- package/dist/conformance/editor-nodeview.d.ts.map +1 -0
- package/dist/conformance/editor-nodeview.js +120 -0
- package/dist/conformance/editor-nodeview.js.map +1 -0
- package/dist/conformance/editor-overlay.d.ts +8 -0
- package/dist/conformance/editor-overlay.d.ts.map +1 -0
- package/dist/conformance/editor-overlay.js +323 -0
- package/dist/conformance/editor-overlay.js.map +1 -0
- package/dist/conformance/fixtures.d.ts +4 -0
- package/dist/conformance/fixtures.d.ts.map +1 -0
- package/dist/conformance/fixtures.js +15 -0
- package/dist/conformance/fixtures.js.map +1 -0
- package/dist/conformance/index.d.ts +14 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +28 -0
- package/dist/conformance/index.js.map +1 -0
- package/dist/conformance/types.d.ts +51 -0
- package/dist/conformance/types.d.ts.map +1 -0
- package/dist/conformance/types.js +2 -0
- package/dist/conformance/types.js.map +1 -0
- package/dist/conformance/widget.d.ts +3 -0
- package/dist/conformance/widget.d.ts.map +1 -0
- package/dist/conformance/widget.js +49 -0
- package/dist/conformance/widget.js.map +1 -0
- package/dist/conformance.d.ts +1 -58
- package/dist/conformance.d.ts.map +1 -1
- package/dist/conformance.js +3 -1748
- package/dist/conformance.js.map +1 -1
- package/dist/editor/extension.d.ts +47 -3
- package/dist/editor/extension.d.ts.map +1 -1
- package/dist/features/ace.d.ts +4 -0
- package/dist/features/ace.d.ts.map +1 -1
- package/dist/features/ace.js +34 -13
- package/dist/features/ace.js.map +1 -1
- package/dist/features/block-dnd.d.ts +7 -0
- package/dist/features/block-dnd.d.ts.map +1 -0
- package/dist/features/block-dnd.js +207 -0
- package/dist/features/block-dnd.js.map +1 -0
- package/dist/features/code-block.d.ts +9 -84
- package/dist/features/code-block.d.ts.map +1 -1
- package/dist/features/code-block.js +37 -1027
- package/dist/features/code-block.js.map +1 -1
- package/dist/features/code-context.d.ts +81 -0
- package/dist/features/code-context.d.ts.map +1 -0
- package/dist/features/code-context.js +322 -0
- package/dist/features/code-context.js.map +1 -0
- package/dist/features/code-edit.d.ts +50 -0
- package/dist/features/code-edit.d.ts.map +1 -0
- package/dist/features/code-edit.js +412 -0
- package/dist/features/code-edit.js.map +1 -0
- package/dist/features/code-format.d.ts +21 -0
- package/dist/features/code-format.d.ts.map +1 -0
- package/dist/features/code-format.js +410 -0
- package/dist/features/code-format.js.map +1 -0
- package/dist/features/doc.d.ts.map +1 -1
- package/dist/features/doc.js +1 -1
- package/dist/features/doc.js.map +1 -1
- package/dist/features/heading.d.ts.map +1 -1
- package/dist/features/heading.js.map +1 -1
- package/dist/features/image.d.ts.map +1 -1
- package/dist/features/image.js +11 -2
- package/dist/features/image.js.map +1 -1
- package/dist/features/index.d.ts +7 -0
- package/dist/features/index.d.ts.map +1 -1
- package/dist/features/index.js +16 -0
- package/dist/features/index.js.map +1 -1
- package/dist/features/inline-style.d.ts.map +1 -1
- package/dist/features/inline-style.js +4 -1
- package/dist/features/inline-style.js.map +1 -1
- package/dist/features/link.d.ts.map +1 -1
- package/dist/features/link.js +1 -1
- package/dist/features/link.js.map +1 -1
- package/dist/features/markdown.d.ts +15 -0
- package/dist/features/markdown.d.ts.map +1 -0
- package/dist/features/markdown.js +80 -0
- package/dist/features/markdown.js.map +1 -0
- package/dist/features/marks.d.ts.map +1 -1
- package/dist/features/marks.js +49 -4
- package/dist/features/marks.js.map +1 -1
- package/dist/features/mention.d.ts +30 -0
- package/dist/features/mention.d.ts.map +1 -0
- package/dist/features/mention.js +89 -0
- package/dist/features/mention.js.map +1 -0
- package/dist/features/script.d.ts.map +1 -1
- package/dist/features/script.js.map +1 -1
- package/dist/features/search.d.ts +73 -0
- package/dist/features/search.d.ts.map +1 -0
- package/dist/features/search.js +252 -0
- package/dist/features/search.js.map +1 -0
- package/dist/features/shared.d.ts +69 -0
- package/dist/features/shared.d.ts.map +1 -0
- package/dist/features/shared.js +272 -0
- package/dist/features/shared.js.map +1 -0
- package/dist/features/table.d.ts.map +1 -1
- package/dist/features/table.js +17 -3
- package/dist/features/table.js.map +1 -1
- package/dist/features/testkit.d.ts.map +1 -1
- package/dist/features/testkit.js +1 -1
- package/dist/features/testkit.js.map +1 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +65 -20
- package/dist/history.js.map +1 -1
- package/dist/index.d.ts +19 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -6
- package/dist/index.js.map +1 -1
- package/dist/model/schema.d.ts.map +1 -1
- package/dist/model/schema.js +2 -1
- package/dist/model/schema.js.map +1 -1
- package/dist/parse/html.d.ts.map +1 -1
- package/dist/parse/html.js +38 -6
- package/dist/parse/html.js.map +1 -1
- package/dist/parse/sanitize.d.ts.map +1 -1
- package/dist/parse/sanitize.js +4 -1
- package/dist/parse/sanitize.js.map +1 -1
- package/dist/presets/highlight.d.ts.map +1 -1
- package/dist/presets/highlight.js +1 -4
- package/dist/presets/highlight.js.map +1 -1
- package/dist/presets/markdown-parse.d.ts +13 -0
- package/dist/presets/markdown-parse.d.ts.map +1 -0
- package/dist/presets/markdown-parse.js +398 -0
- package/dist/presets/markdown-parse.js.map +1 -0
- package/dist/presets/markdown.d.ts +45 -0
- package/dist/presets/markdown.d.ts.map +1 -0
- package/dist/presets/markdown.js +195 -0
- package/dist/presets/markdown.js.map +1 -0
- package/dist/state/apply.d.ts.map +1 -1
- package/dist/state/apply.js +19 -11
- package/dist/state/apply.js.map +1 -1
- package/dist/state/delete-ops.d.ts +34 -0
- package/dist/state/delete-ops.d.ts.map +1 -0
- package/dist/state/delete-ops.js +204 -0
- package/dist/state/delete-ops.js.map +1 -0
- package/dist/state/input-rules.d.ts +30 -0
- package/dist/state/input-rules.d.ts.map +1 -0
- package/dist/state/input-rules.js +70 -0
- package/dist/state/input-rules.js.map +1 -0
- package/dist/state/insert-ops.d.ts +54 -0
- package/dist/state/insert-ops.d.ts.map +1 -0
- package/dist/state/insert-ops.js +186 -0
- package/dist/state/insert-ops.js.map +1 -0
- package/dist/state/list-ops.d.ts +46 -0
- package/dist/state/list-ops.d.ts.map +1 -0
- package/dist/state/list-ops.js +231 -0
- package/dist/state/list-ops.js.map +1 -0
- package/dist/state/navigate.d.ts.map +1 -1
- package/dist/state/navigate.js +3 -1
- package/dist/state/navigate.js.map +1 -1
- package/dist/state/resolve-insert.d.ts +55 -0
- package/dist/state/resolve-insert.d.ts.map +1 -0
- package/dist/state/resolve-insert.js +118 -0
- package/dist/state/resolve-insert.js.map +1 -0
- package/dist/state/slice-ops.d.ts +46 -0
- package/dist/state/slice-ops.d.ts.map +1 -0
- package/dist/state/slice-ops.js +150 -0
- package/dist/state/slice-ops.js.map +1 -0
- package/dist/state/state.d.ts +6 -1
- package/dist/state/state.d.ts.map +1 -1
- package/dist/state/state.js +10 -0
- package/dist/state/state.js.map +1 -1
- package/dist/state/transaction.d.ts +43 -49
- package/dist/state/transaction.d.ts.map +1 -1
- package/dist/state/transaction.js +62 -858
- package/dist/state/transaction.js.map +1 -1
- package/dist/styles.css +160 -29
- package/dist/view/adapter.d.ts +84 -0
- package/dist/view/adapter.d.ts.map +1 -0
- package/dist/view/adapter.js +76 -0
- package/dist/view/adapter.js.map +1 -0
- package/dist/view/caret.d.ts +25 -0
- package/dist/view/caret.d.ts.map +1 -0
- package/dist/view/caret.js +43 -0
- package/dist/view/caret.js.map +1 -0
- package/dist/view/decoration.d.ts +9 -0
- package/dist/view/decoration.d.ts.map +1 -0
- package/dist/view/decoration.js +214 -0
- package/dist/view/decoration.js.map +1 -0
- package/dist/view/dirty-keys.d.ts +18 -0
- package/dist/view/dirty-keys.d.ts.map +1 -0
- package/dist/view/dirty-keys.js +31 -0
- package/dist/view/dirty-keys.js.map +1 -0
- package/dist/view/dom.d.ts.map +1 -1
- package/dist/view/dom.js +7 -5
- package/dist/view/dom.js.map +1 -1
- package/dist/view/hook-ctx.d.ts +32 -0
- package/dist/view/hook-ctx.d.ts.map +1 -0
- package/dist/view/hook-ctx.js +64 -0
- package/dist/view/hook-ctx.js.map +1 -0
- package/dist/view/ime.d.ts +72 -0
- package/dist/view/ime.d.ts.map +1 -0
- package/dist/view/ime.js +185 -0
- package/dist/view/ime.js.map +1 -0
- package/dist/view/input.d.ts +12 -12
- package/dist/view/input.d.ts.map +1 -1
- package/dist/view/input.js +83 -534
- package/dist/view/input.js.map +1 -1
- package/dist/view/keys.d.ts +25 -0
- package/dist/view/keys.d.ts.map +1 -0
- package/dist/view/keys.js +106 -0
- package/dist/view/keys.js.map +1 -0
- package/dist/view/line-nav.d.ts +13 -0
- package/dist/view/line-nav.d.ts.map +1 -0
- package/dist/view/line-nav.js +114 -0
- package/dist/view/line-nav.js.map +1 -0
- package/dist/view/mount.d.ts.map +1 -1
- package/dist/view/mount.js +43 -39
- package/dist/view/mount.js.map +1 -1
- package/dist/view/node-selection.d.ts +33 -0
- package/dist/view/node-selection.d.ts.map +1 -0
- package/dist/view/node-selection.js +107 -0
- package/dist/view/node-selection.js.map +1 -0
- package/dist/view/render.d.ts +4 -1
- package/dist/view/render.d.ts.map +1 -1
- package/dist/view/render.js +13 -3
- package/dist/view/render.js.map +1 -1
- package/dist/view/serialize.d.ts.map +1 -1
- package/dist/view/serialize.js +14 -5
- package/dist/view/serialize.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { codeLangOptions, highlightCode, } from "../presets/highlight.js";
|
|
1
|
+
import { nodeAt } from "../model/traverse.js";
|
|
2
|
+
import { normalizedRange, pointEq, textSelection } from "../state/selection.js";
|
|
3
|
+
import { codeLangOptions, highlightCode } from "../presets/highlight.js";
|
|
5
4
|
import { PATH_ATTR } from "../view/dom.js";
|
|
6
5
|
import { defineFeature } from "./feature.js";
|
|
6
|
+
import { codeBracketAt, codeContext, codeStringMask, codeTextAt, isCodeBlock, } from "./code-context.js";
|
|
7
|
+
import { dedentCode, duplicateCodeLines, exitCodeArrow, formatCode, formatCodeAt, indentCode, moveCodeLines, toggleCodeComment, } from "./code-edit.js";
|
|
7
8
|
import { blockRule, blockText, blockTextPoint, caretBlockPath, } from "../commands.js";
|
|
9
|
+
/**
|
|
10
|
+
* 코드블록 — ` ``` ` 입력 규칙, 구문 하이라이트, Ace/VS Code 관례의
|
|
11
|
+
* 코드 편집 명령(들여쓰기·주석 토글·줄 이동/복제·괄호 매칭·화살표
|
|
12
|
+
* 탈출). 코드 텍스트는 마크 없는 순수 텍스트다.
|
|
13
|
+
*
|
|
14
|
+
* 모듈 구성 — 이 파일은 스키마·블록 변환·크롬 UI·feature 정의만 갖고,
|
|
15
|
+
* 컨텍스트 해석은 code-context.ts, 편집 커맨드는 code-edit.ts,
|
|
16
|
+
* 서식 엔진은 code-format.ts에 있다.
|
|
17
|
+
*/
|
|
8
18
|
/**
|
|
9
19
|
* 코드블록 — ` ``` ` 입력 규칙, 구문 하이라이트, Ace/VS Code 관례의
|
|
10
20
|
* 코드 편집 명령(들여쓰기·주석 토글·줄 이동/복제·괄호 매칭·화살표
|
|
@@ -43,16 +53,17 @@ export const toggleCodeBlock = ({ state, tr, dispatch }) => {
|
|
|
43
53
|
return false;
|
|
44
54
|
const merged = {
|
|
45
55
|
type: "codeBlock",
|
|
46
|
-
content: [
|
|
47
|
-
{ type: "text", text: blocks.map(blockText).join("\n") },
|
|
48
|
-
],
|
|
56
|
+
content: [{ type: "text", text: blocks.map(blockText).join("\n") }],
|
|
49
57
|
};
|
|
50
58
|
tr.delete({
|
|
51
59
|
anchor: { path: [], offset: a },
|
|
52
60
|
focus: { path: [], offset: b + 1 },
|
|
53
61
|
});
|
|
54
62
|
tr.insertNode({ path: [], offset: a }, merged);
|
|
55
|
-
tr.setSelection(textSelection({
|
|
63
|
+
tr.setSelection(textSelection({
|
|
64
|
+
path: [a, 0],
|
|
65
|
+
offset: merged.content?.[0]?.text?.length ?? 0,
|
|
66
|
+
}));
|
|
56
67
|
dispatch();
|
|
57
68
|
return true;
|
|
58
69
|
}
|
|
@@ -91,1014 +102,6 @@ export function setCodeLanguageAt(blockPath, language) {
|
|
|
91
102
|
return true;
|
|
92
103
|
};
|
|
93
104
|
}
|
|
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
|
-
}
|
|
368
|
-
/**
|
|
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
|
-
* 선택에서도 코드 블록 구간만 정확히 잘라낸다.
|
|
468
|
-
*/
|
|
469
|
-
function codeSelectionContexts(state) {
|
|
470
|
-
const sel = state.selection;
|
|
471
|
-
if (sel.type !== "text")
|
|
472
|
-
return [];
|
|
473
|
-
const r = normalizedRange(sel);
|
|
474
|
-
const aLeaf = leafPoint(state.doc, r.anchor);
|
|
475
|
-
const fLeaf = leafPoint(state.doc, r.focus);
|
|
476
|
-
const loI = aLeaf.path[0];
|
|
477
|
-
const hiI = fLeaf.path[0];
|
|
478
|
-
if (loI == null || hiI == null)
|
|
479
|
-
return [];
|
|
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
|
-
};
|
|
526
|
-
for (let i = loI; i <= hiI && i < (state.doc.content?.length ?? 0); i++) {
|
|
527
|
-
visit(state.doc.content[i], [i]);
|
|
528
|
-
}
|
|
529
|
-
return out;
|
|
530
|
-
}
|
|
531
|
-
/**
|
|
532
|
-
* 캐럿(포커스)이 속한 코드 컨텍스트 — 포커스 리프가 그 블록 안에
|
|
533
|
-
* 있어야 한다 (화살표 탈출·들여쓰기 등 캐럿 명령용). 선택만 걸치고
|
|
534
|
-
* 포커스는 비코드 블록에 있으면 null.
|
|
535
|
-
*/
|
|
536
|
-
function codeContext(state) {
|
|
537
|
-
const sel = state.selection;
|
|
538
|
-
if (sel.type !== "text")
|
|
539
|
-
return null;
|
|
540
|
-
const r = normalizedRange(sel);
|
|
541
|
-
const fLeaf = leafPoint(state.doc, r.focus);
|
|
542
|
-
// 리프가 텍스트면 부모가 블록, 컨테이너(빈 코드블록 등)면 자기 자신
|
|
543
|
-
const fBlock = nodeAt(state.doc, fLeaf.path)?.type === "text"
|
|
544
|
-
? fLeaf.path.slice(0, -1)
|
|
545
|
-
: fLeaf.path;
|
|
546
|
-
for (const ctx of codeSelectionContexts(state)) {
|
|
547
|
-
if (ctx.blockPath.length === fBlock.length &&
|
|
548
|
-
ctx.blockPath.every((v, i) => v === fBlock[i])) {
|
|
549
|
-
return ctx;
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
return null;
|
|
553
|
-
}
|
|
554
|
-
/** 블록 포인트 → 병합 문자 오프셋. 블록 밖 포인트면 null */
|
|
555
|
-
function charOffset(block, blockPath, p) {
|
|
556
|
-
const kids = block.content ?? [];
|
|
557
|
-
const rel = p.path.slice(blockPath.length);
|
|
558
|
-
if (!p.path.slice(0, blockPath.length).every((v, i) => v === blockPath[i])) {
|
|
559
|
-
return null;
|
|
560
|
-
}
|
|
561
|
-
if (rel.length === 0) {
|
|
562
|
-
// 컨테이너 포인트 — 앞쪽 자식들의 텍스트 합
|
|
563
|
-
let sum = 0;
|
|
564
|
-
for (let i = 0; i < Math.min(p.offset, kids.length); i++) {
|
|
565
|
-
sum += kids[i].text?.length ?? 0;
|
|
566
|
-
}
|
|
567
|
-
return sum;
|
|
568
|
-
}
|
|
569
|
-
if (rel.length !== 1)
|
|
570
|
-
return null;
|
|
571
|
-
let sum = p.offset;
|
|
572
|
-
for (let i = 0; i < rel[0] && i < kids.length; i++) {
|
|
573
|
-
sum += kids[i].text?.length ?? 0;
|
|
574
|
-
}
|
|
575
|
-
return sum;
|
|
576
|
-
}
|
|
577
|
-
/** 병합 문자 오프셋 → 텍스트 포인트 (기존 자식 경계로 해석) */
|
|
578
|
-
function charPoint(block, blockPath, off, lens) {
|
|
579
|
-
const kids = block.content ?? [];
|
|
580
|
-
let rest = off;
|
|
581
|
-
for (let i = 0; i < kids.length; i++) {
|
|
582
|
-
const t = lens?.[i] ?? kids[i].text?.length ?? 0;
|
|
583
|
-
if (rest <= t || i === kids.length - 1) {
|
|
584
|
-
return { path: [...blockPath, i], offset: Math.min(rest, t) };
|
|
585
|
-
}
|
|
586
|
-
rest -= t;
|
|
587
|
-
}
|
|
588
|
-
return { path: [...blockPath, 0], offset: 0 };
|
|
589
|
-
}
|
|
590
|
-
/**
|
|
591
|
-
* 문자열/주석 토큰 안의 문자 오프셋 판정 — spec.codeHighlighter로
|
|
592
|
-
* 토큰화해 그 구간에 있으면 true를 돌린다 (Ace가 페어링·매칭을
|
|
593
|
-
* 끌 때 쓰는 isCodeLike 관례). 하이라이터가 없으면 null.
|
|
594
|
-
*/
|
|
595
|
-
export function codeStringMask(state, block) {
|
|
596
|
-
const hl = state.schema.nodeSpec(block.type)?.codeHighlighter;
|
|
597
|
-
if (!hl)
|
|
598
|
-
return null;
|
|
599
|
-
const text = (block.content ?? []).map((c) => c.text ?? "").join("");
|
|
600
|
-
const lang = block.attrs?.["language"] ?? null;
|
|
601
|
-
const spans = [];
|
|
602
|
-
let i = 0;
|
|
603
|
-
for (const t of hl(text, lang)) {
|
|
604
|
-
const end = i + t.text.length;
|
|
605
|
-
if (t.kind === "string" || t.kind === "comment") {
|
|
606
|
-
const first = t.text[0] ?? "";
|
|
607
|
-
// 열린 토큰(//·# 라인 주석, 닫히지 않은 문자열/블록 주석)은
|
|
608
|
-
// 끝 경계도 안쪽으로 본다 — 그 자리의 입력이 토큰을 이어받는다
|
|
609
|
-
const open = first === "#" ||
|
|
610
|
-
(first === "/" && !t.text.endsWith("*/")) ||
|
|
611
|
-
(first !== "/" &&
|
|
612
|
-
first !== "#" &&
|
|
613
|
-
!(t.text.length > 1 && t.text.endsWith(first)));
|
|
614
|
-
spans.push([i, open ? end + 1 : end]);
|
|
615
|
-
}
|
|
616
|
-
i = end;
|
|
617
|
-
}
|
|
618
|
-
return (off) => spans.some(([a, b]) => off >= a && off < b);
|
|
619
|
-
}
|
|
620
|
-
/** 문자열의 줄 시작 오프셋 목록 (0과 각 '\n' 직후) */
|
|
621
|
-
function lineStarts(text) {
|
|
622
|
-
const out = [0];
|
|
623
|
-
for (let i = 0; i < text.length; i++) {
|
|
624
|
-
if (text[i] === "\n")
|
|
625
|
-
out.push(i + 1);
|
|
626
|
-
}
|
|
627
|
-
return out;
|
|
628
|
-
}
|
|
629
|
-
const CODE_TAB = "\t";
|
|
630
|
-
const CODE_TAB_SIZE = 4;
|
|
631
|
-
// ── 들여쓰기 (VS Code 관례) ───────────────────────────────
|
|
632
|
-
/**
|
|
633
|
-
* Tab — 코드블록 들여쓰기. collapsed면 캐럿에 \t 삽입,
|
|
634
|
-
* 범위 선택이면 걸친 줄 전부의 앞에 \t를 단다 (VS Code 관례).
|
|
635
|
-
* 코드블록 밖이면 false — 리스트/표 키바인딩으로 폴스루.
|
|
636
|
-
*/
|
|
637
|
-
export const indentCode = ({ state, tr, dispatch }) => {
|
|
638
|
-
const ctx = codeContext(state);
|
|
639
|
-
if (!ctx)
|
|
640
|
-
return false;
|
|
641
|
-
const { blockPath, block, text, from, to } = ctx;
|
|
642
|
-
if (from === to) {
|
|
643
|
-
const at = charPoint(block, blockPath, from);
|
|
644
|
-
tr.insertText(CODE_TAB, at);
|
|
645
|
-
// 삽입 후 길이로 해석해야 from+1이 삽입 전 길이에 클램프되지 않는다
|
|
646
|
-
const kidIdx = at.path[at.path.length - 1];
|
|
647
|
-
const lens = (block.content ?? []).map((k, i) => (k.text?.length ?? 0) + (i === kidIdx ? 1 : 0));
|
|
648
|
-
tr.setSelection(textSelection(charPoint(block, blockPath, from + 1, lens)));
|
|
649
|
-
dispatch();
|
|
650
|
-
return true;
|
|
651
|
-
}
|
|
652
|
-
// 걸친 줄들의 시작 — 'to'가 정확히 줄 시작이면 그 줄은 제외
|
|
653
|
-
const starts = lineStarts(text);
|
|
654
|
-
const start0 = Math.max(...starts.filter((s) => s <= from));
|
|
655
|
-
const covered = starts.filter((s) => s >= start0 && s < to);
|
|
656
|
-
if (!covered.length)
|
|
657
|
-
return false;
|
|
658
|
-
// 뒤쪽 줄부터 삽입해야 앞쪽 좌표가 안 밀린다
|
|
659
|
-
const kids = block.content ?? [];
|
|
660
|
-
const extra = kids.map(() => 0);
|
|
661
|
-
for (const s of [...covered].sort((a, b) => b - a)) {
|
|
662
|
-
const p = charPoint(block, blockPath, s);
|
|
663
|
-
tr.insertText(CODE_TAB, p);
|
|
664
|
-
extra[p.path[p.path.length - 1]] += 1;
|
|
665
|
-
}
|
|
666
|
-
const lens = kids.map((k, i) => (k.text?.length ?? 0) + extra[i]);
|
|
667
|
-
const shift = (off) => off + covered.filter((s) => s <= off).length;
|
|
668
|
-
tr.setSelection(textSelection(charPoint(block, blockPath, shift(from), lens), charPoint(block, blockPath, shift(to), lens)));
|
|
669
|
-
dispatch();
|
|
670
|
-
return true;
|
|
671
|
-
};
|
|
672
|
-
/**
|
|
673
|
-
* Shift-Tab — 코드블록 내어쓰기. 걸친 각 줄의 앞에서 \t 하나 또는
|
|
674
|
-
* 스페이스 최대 tabSize개를 지운다. 코드블록 밖이면 false로 폴스루.
|
|
675
|
-
*/
|
|
676
|
-
export const dedentCode = ({ state, tr, dispatch }) => {
|
|
677
|
-
const ctx = codeContext(state);
|
|
678
|
-
if (!ctx)
|
|
679
|
-
return false;
|
|
680
|
-
const { blockPath, block, text, from, to } = ctx;
|
|
681
|
-
const starts = lineStarts(text);
|
|
682
|
-
const start0 = Math.max(...starts.filter((s) => s <= from));
|
|
683
|
-
// 각 줄에서 지울 들여쓰기 — \t 하나 또는 앞쪽 연속 스페이스 최대 tabSize개
|
|
684
|
-
const edits = starts
|
|
685
|
-
.filter((s) => s >= start0 && (from === to ? s <= from : s < to))
|
|
686
|
-
.map((s) => {
|
|
687
|
-
let n = 0;
|
|
688
|
-
if (text[s] === CODE_TAB)
|
|
689
|
-
n = 1;
|
|
690
|
-
else
|
|
691
|
-
while (n < CODE_TAB_SIZE && text[s + n] === " ")
|
|
692
|
-
n++;
|
|
693
|
-
return { s, n };
|
|
694
|
-
})
|
|
695
|
-
.filter((e) => e.n > 0);
|
|
696
|
-
// 코드블록 안인데 지울 들여쓰기가 없으면 그냥 삼킨다 — false로
|
|
697
|
-
// 폴스루하면 브라우저가 포커스를 에디터 밖으로 옮긴다 (Ace/VS Code는 no-op)
|
|
698
|
-
if (!edits.length)
|
|
699
|
-
return true;
|
|
700
|
-
const kids = block.content ?? [];
|
|
701
|
-
const extra = kids.map(() => 0);
|
|
702
|
-
for (const { s, n } of [...edits].sort((a, b) => b.s - a.s)) {
|
|
703
|
-
const a = charPoint(block, blockPath, s);
|
|
704
|
-
tr.delete({
|
|
705
|
-
anchor: a,
|
|
706
|
-
focus: { path: a.path, offset: a.offset + n },
|
|
707
|
-
});
|
|
708
|
-
extra[a.path[a.path.length - 1]] -= n;
|
|
709
|
-
}
|
|
710
|
-
const lens = kids.map((k, i) => (k.text?.length ?? 0) + extra[i]);
|
|
711
|
-
// 지워진 들여쓰기만큼 좌표를 당긴다 — 줄 시작(s === off) 위치의
|
|
712
|
-
// 캐럿은 들여쓰기 앞에 머문다 (s < off 기준)
|
|
713
|
-
const shift = (off) => Math.max(0, off - edits.reduce((d, e) => d + (e.s < off ? e.n : 0), 0));
|
|
714
|
-
tr.setSelection(textSelection(charPoint(block, blockPath, shift(from), lens), charPoint(block, blockPath, shift(to), lens)));
|
|
715
|
-
dispatch();
|
|
716
|
-
return true;
|
|
717
|
-
};
|
|
718
|
-
/**
|
|
719
|
-
* ArrowDown/ArrowUp — 코드블록 가장자리 줄 탈출 (Tiptap
|
|
720
|
-
* exitOnArrowDown/Up 관례). 마지막 줄에서 ArrowDown을 누르면 블록
|
|
721
|
-
* 뒤로, 첫 줄에서 ArrowUp이면 블록 앞으로 나간다 — 이웃 블록이
|
|
722
|
-
* 없으면 빈 문단을 만든다. Notion의 코드블록 화살표 탈출과 같다.
|
|
723
|
-
*/
|
|
724
|
-
export function exitCodeArrow(direction) {
|
|
725
|
-
return ({ state, tr, dispatch }) => {
|
|
726
|
-
const ctx = codeContext(state);
|
|
727
|
-
if (!ctx || ctx.from !== ctx.to)
|
|
728
|
-
return false;
|
|
729
|
-
const { blockPath, text, from } = ctx;
|
|
730
|
-
const onEdge = direction === "down"
|
|
731
|
-
? !text.slice(from).includes("\n")
|
|
732
|
-
: !text.slice(0, from).includes("\n");
|
|
733
|
-
if (!onEdge)
|
|
734
|
-
return false;
|
|
735
|
-
const parentPath = blockPath.slice(0, -1);
|
|
736
|
-
const bi = blockPath[blockPath.length - 1];
|
|
737
|
-
const si = direction === "down" ? bi + 1 : bi - 1;
|
|
738
|
-
const sib = si >= 0 ? nodeAt(state.doc, [...parentPath, si]) : undefined;
|
|
739
|
-
if (sib) {
|
|
740
|
-
const tp = direction === "down" ? firstTextPath(sib) : null;
|
|
741
|
-
const sp = tp
|
|
742
|
-
? [...parentPath, si, ...tp]
|
|
743
|
-
: [...parentPath, si];
|
|
744
|
-
tr.setSelection(textSelection({ path: sp, offset: 0 }));
|
|
745
|
-
dispatch();
|
|
746
|
-
return true;
|
|
747
|
-
}
|
|
748
|
-
// 이웃이 없으면 빈 문단을 만들어 탈출 — bi=0에서 ArrowUp이면
|
|
749
|
-
// 코드블록 앞에, 끝에서 ArrowDown이면 뒤에 놓인다
|
|
750
|
-
const at = { path: parentPath, offset: direction === "down" ? bi + 1 : bi };
|
|
751
|
-
tr.insertNode(at, { type: "paragraph", content: [] });
|
|
752
|
-
tr.setSelection(textSelection({ path: [...at.path, at.offset], offset: 0 }));
|
|
753
|
-
dispatch();
|
|
754
|
-
return true;
|
|
755
|
-
};
|
|
756
|
-
}
|
|
757
|
-
/** 캐럿 코드블록의 텍스트 — 복사 버튼 등 UI용. 밖이면 null */
|
|
758
|
-
export function codeTextAt(state) {
|
|
759
|
-
const ctx = codeContext(state);
|
|
760
|
-
return ctx ? ctx.text : null;
|
|
761
|
-
}
|
|
762
|
-
/** 코드블록 안 캐럿의 줄/열 (Ace 상태바 Ln:Col 관례). 밖이면 null */
|
|
763
|
-
export function codeCaret(state) {
|
|
764
|
-
const ctx = codeContext(state);
|
|
765
|
-
if (!ctx)
|
|
766
|
-
return null;
|
|
767
|
-
const before = ctx.text.slice(0, ctx.to);
|
|
768
|
-
const nl = before.lastIndexOf("\n");
|
|
769
|
-
return { line: before.split("\n").length, col: before.length - nl };
|
|
770
|
-
}
|
|
771
|
-
// ── 코드 편집 명령 — 주석 토글·줄 이동/복제·괄호 매칭 (Ace 관례) ──
|
|
772
|
-
/** 선택이 걸친 줄 범위 [lo, hi] — to가 정확히 줄 시작이면 그 줄은 제외 */
|
|
773
|
-
function codeLineRange(text, from, to) {
|
|
774
|
-
const starts = lineStarts(text);
|
|
775
|
-
const lo = starts.filter((s) => s <= from).length - 1;
|
|
776
|
-
let hi = starts.filter((s) => s <= to).length - 1;
|
|
777
|
-
if (to > from && starts[hi] === to)
|
|
778
|
-
hi--;
|
|
779
|
-
return [Math.max(0, lo), Math.max(0, hi)];
|
|
780
|
-
}
|
|
781
|
-
/**
|
|
782
|
-
* 문자 오프셋 편집 목록(삽입/삭제)을 블록에 적용하고 새 doc 기준
|
|
783
|
-
* 텍스트 자식별 길이 배열을 돌려준다. ops는 겹치지 않는 원본 좌표 —
|
|
784
|
-
* 뒤쪽(큰 pos)부터 적용해야 앞쪽 좌표가 안 밀린다.
|
|
785
|
-
*/
|
|
786
|
-
function applyCharOps(tr, block, blockPath, ops) {
|
|
787
|
-
const kids = block.content ?? [];
|
|
788
|
-
const extra = kids.map(() => 0);
|
|
789
|
-
const ends = [];
|
|
790
|
-
let acc = 0;
|
|
791
|
-
for (const k of kids) {
|
|
792
|
-
acc += k.text?.length ?? 0;
|
|
793
|
-
ends.push(acc);
|
|
794
|
-
}
|
|
795
|
-
for (const op of [...ops].sort((a, b) => b.pos - a.pos)) {
|
|
796
|
-
if (op.del) {
|
|
797
|
-
tr.delete({
|
|
798
|
-
anchor: charPoint(block, blockPath, op.pos),
|
|
799
|
-
focus: charPoint(block, blockPath, op.pos + op.del),
|
|
800
|
-
});
|
|
801
|
-
for (let i = 0; i < kids.length; i++) {
|
|
802
|
-
const ks = i === 0 ? 0 : ends[i - 1];
|
|
803
|
-
const ov = Math.min(op.pos + op.del, ends[i]) - Math.max(op.pos, ks);
|
|
804
|
-
if (ov > 0)
|
|
805
|
-
extra[i] -= ov;
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
if (op.ins) {
|
|
809
|
-
const at = charPoint(block, blockPath, op.pos);
|
|
810
|
-
tr.insertText(op.ins, at);
|
|
811
|
-
extra[at.path[at.path.length - 1]] += op.ins.length;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
return kids.map((k, i) => (k.text?.length ?? 0) + extra[i]);
|
|
815
|
-
}
|
|
816
|
-
/** ops 기준으로 문자 오프셋을 새 좌표로 옮긴다 */
|
|
817
|
-
function shiftCharOff(ops, off) {
|
|
818
|
-
let v = off;
|
|
819
|
-
for (const op of [...ops].sort((a, b) => a.pos - b.pos)) {
|
|
820
|
-
if (op.pos > off)
|
|
821
|
-
break;
|
|
822
|
-
const ins = op.ins?.length ?? 0;
|
|
823
|
-
const del = op.del ?? 0;
|
|
824
|
-
if (op.pos + del <= off)
|
|
825
|
-
v += ins - del;
|
|
826
|
-
else
|
|
827
|
-
v = op.pos + ins;
|
|
828
|
-
}
|
|
829
|
-
return Math.max(0, v);
|
|
830
|
-
}
|
|
831
|
-
/** 언어 → 주석 문법. 문자열=줄 주석, 쌍=블록 래핑 (Ace mode 주석 관례) */
|
|
832
|
-
const CODE_COMMENT = {
|
|
833
|
-
py: "#", python: "#", rb: "#", ruby: "#", pl: "#", perl: "#",
|
|
834
|
-
sh: "#", bash: "#", zsh: "#", shell: "#", yaml: "#", yml: "#",
|
|
835
|
-
toml: "#", ini: "#", dockerfile: "#", makefile: "#", make: "#",
|
|
836
|
-
r: "#", coffee: "#", ps1: "#", powershell: "#", nim: "#",
|
|
837
|
-
sql: "--", lua: "--", haskell: "--", hs: "--", ada: "--",
|
|
838
|
-
css: ["/*", "*/"], scss: ["/*", "*/"], less: ["/*", "*/"],
|
|
839
|
-
html: ["<!--", "-->"], xml: ["<!--", "-->"], svg: ["<!--", "-->"],
|
|
840
|
-
md: ["<!--", "-->"], markdown: ["<!--", "-->"],
|
|
841
|
-
};
|
|
842
|
-
/**
|
|
843
|
-
* Mod-/ — 코드블록 줄 주석 토글 (Ace toggleCommentLines 관례).
|
|
844
|
-
* 걸친 줄이 전부 주석이면 해제, 아니면 주석을 단다 — 마커는
|
|
845
|
-
* 선행 공백 뒤에 붙어 들여쓰기가 보존된다 (VS Code 관례).
|
|
846
|
-
* 블록 주석 언어(css/html 등)는 줄마다 블록 마커로 감싼다.
|
|
847
|
-
*/
|
|
848
|
-
export const toggleCodeComment = ({ state, tr, dispatch }) => {
|
|
849
|
-
// 선택과 겹치는 모든 코드 블록에 적용 — 전체 선택(Mod-A)이 여러
|
|
850
|
-
// 블록을 걸쳐도 코드 구간을 빠뜨리지 않는다 (VS Code 관례)
|
|
851
|
-
const ctxs = codeSelectionContexts(state);
|
|
852
|
-
if (!ctxs.length)
|
|
853
|
-
return false;
|
|
854
|
-
const parts = ctxs.map((ctx) => {
|
|
855
|
-
const lang = String(ctx.block.attrs?.["language"] ?? "").toLowerCase();
|
|
856
|
-
const lines = ctx.text.split("\n");
|
|
857
|
-
const [lo, hi] = codeLineRange(ctx.text, ctx.from, ctx.to);
|
|
858
|
-
return {
|
|
859
|
-
ctx,
|
|
860
|
-
syn: CODE_COMMENT[lang] ?? "//",
|
|
861
|
-
lines,
|
|
862
|
-
starts: lineStarts(ctx.text),
|
|
863
|
-
indent: (i) => lines[i].match(/^[ \t]*/)[0].length,
|
|
864
|
-
idx: Array.from({ length: hi - lo + 1 }, (_, k) => lo + k).filter((i) => lines[i].trim().length > 0),
|
|
865
|
-
};
|
|
866
|
-
});
|
|
867
|
-
if (!parts.some((p) => p.idx.length))
|
|
868
|
-
return false;
|
|
869
|
-
// 전부 주석이면 해제, 하나라도 아니면 주석 (블록을 넘나드는 선택도
|
|
870
|
-
// 같은 규칙 — VS Code는 선택 전체 기준으로 토글한다)
|
|
871
|
-
const commentedOf = (p, i) => {
|
|
872
|
-
const t = p.lines[i].slice(p.indent(i));
|
|
873
|
-
return typeof p.syn === "string"
|
|
874
|
-
? t.startsWith(p.syn)
|
|
875
|
-
: t.trimEnd().startsWith(p.syn[0]) &&
|
|
876
|
-
t.trimEnd().endsWith(p.syn[1]) &&
|
|
877
|
-
t.trimEnd().length > p.syn[0].length + p.syn[1].length;
|
|
878
|
-
};
|
|
879
|
-
const allCommented = parts.every((p) => p.idx.every((i) => commentedOf(p, i)));
|
|
880
|
-
const edits = parts.map((p) => {
|
|
881
|
-
const { ctx, syn, lines, starts, idx, indent } = p;
|
|
882
|
-
const ops = [];
|
|
883
|
-
if (typeof syn === "string") {
|
|
884
|
-
if (allCommented) {
|
|
885
|
-
for (const i of idx) {
|
|
886
|
-
if (!commentedOf(p, i))
|
|
887
|
-
continue;
|
|
888
|
-
let n = syn.length;
|
|
889
|
-
if (lines[i][indent(i) + syn.length] === " ")
|
|
890
|
-
n += 1;
|
|
891
|
-
ops.push({ pos: starts[i] + indent(i), del: n });
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
else {
|
|
895
|
-
for (const i of idx) {
|
|
896
|
-
ops.push({ pos: starts[i] + indent(i), ins: `${syn} ` });
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
else {
|
|
901
|
-
const [o, c] = syn;
|
|
902
|
-
if (allCommented) {
|
|
903
|
-
for (const i of idx) {
|
|
904
|
-
if (!commentedOf(p, i))
|
|
905
|
-
continue;
|
|
906
|
-
const bp = starts[i] + indent(i);
|
|
907
|
-
const t = lines[i].slice(indent(i));
|
|
908
|
-
let open = o.length;
|
|
909
|
-
if (t[o.length] === " ")
|
|
910
|
-
open += 1;
|
|
911
|
-
const te = t.trimEnd().length;
|
|
912
|
-
let cs = te - c.length;
|
|
913
|
-
if (t[cs - 1] === " ")
|
|
914
|
-
cs -= 1;
|
|
915
|
-
ops.push({ pos: bp, del: open });
|
|
916
|
-
ops.push({ pos: bp + cs, del: te - cs });
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
else {
|
|
920
|
-
for (const i of idx) {
|
|
921
|
-
const bp = starts[i] + indent(i);
|
|
922
|
-
const te = lines[i].trimEnd().length;
|
|
923
|
-
ops.push({ pos: bp, ins: `${o} ` });
|
|
924
|
-
ops.push({ pos: bp + te, ins: ` ${c}` });
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
return ops;
|
|
929
|
-
});
|
|
930
|
-
const lens = parts.map((p, i) => applyCharOps(tr, p.ctx.block, p.ctx.blockPath, edits[i]));
|
|
931
|
-
// 셀렉션 복원 — 코드블록 안 엔드포인트는 삽입/삭제만큼 옮기고,
|
|
932
|
-
// 바깥(전체 선택의 비코드 쪽) 엔드포인트는 원래 위치를 유지한다
|
|
933
|
-
if (state.selection.type !== "text")
|
|
934
|
-
return false;
|
|
935
|
-
const selR = normalizedRange(state.selection);
|
|
936
|
-
const remap = (leaf) => {
|
|
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]));
|
|
941
|
-
if (pi < 0)
|
|
942
|
-
return leaf;
|
|
943
|
-
const p = parts[pi];
|
|
944
|
-
const off = charOffset(p.ctx.block, p.ctx.blockPath, leaf);
|
|
945
|
-
if (off == null)
|
|
946
|
-
return leaf;
|
|
947
|
-
return charPoint(p.ctx.block, p.ctx.blockPath, shiftCharOff(edits[pi], off), lens[pi]);
|
|
948
|
-
};
|
|
949
|
-
tr.setSelection(textSelection(remap(leafPoint(state.doc, selR.anchor)), remap(leafPoint(state.doc, selR.focus))));
|
|
950
|
-
dispatch();
|
|
951
|
-
return true;
|
|
952
|
-
};
|
|
953
|
-
/**
|
|
954
|
-
* Alt-↑/↓ — 선택이 걸친 줄들을 위/아래 한 줄 이동 (Ace movelines·
|
|
955
|
-
* VS Code 관례). 첫/마지막 줄 가장자리에선 false로 폴스루한다.
|
|
956
|
-
*/
|
|
957
|
-
export function moveCodeLines(direction) {
|
|
958
|
-
return ({ state, tr, dispatch }) => {
|
|
959
|
-
const ctx = codeContext(state);
|
|
960
|
-
if (!ctx)
|
|
961
|
-
return false;
|
|
962
|
-
const { blockPath, block, text, from, to } = ctx;
|
|
963
|
-
const lines = text.split("\n");
|
|
964
|
-
const starts = lineStarts(text);
|
|
965
|
-
const [lo, hi] = codeLineRange(text, from, to);
|
|
966
|
-
const sA = starts[lo];
|
|
967
|
-
// sB는 다음 줄 시작 — 블록과 사이의 개행은 sB-1에 있다
|
|
968
|
-
const sB = hi + 1 < lines.length ? starts[hi + 1] : text.length;
|
|
969
|
-
let ops;
|
|
970
|
-
let delta;
|
|
971
|
-
if (direction === "up") {
|
|
972
|
-
if (lo === 0)
|
|
973
|
-
return false;
|
|
974
|
-
const prev = lines[lo - 1];
|
|
975
|
-
const pStart = starts[lo - 1];
|
|
976
|
-
ops =
|
|
977
|
-
sB === text.length
|
|
978
|
-
? // 블록이 마지막 줄 — "block\nprev"가 되도록 끝에 붙인다
|
|
979
|
-
[
|
|
980
|
-
{ pos: sB, ins: `\n${prev}` },
|
|
981
|
-
{ pos: pStart, del: sA - pStart },
|
|
982
|
-
]
|
|
983
|
-
: // 다음 줄 시작(sB)에 "prev\n"을 끼운다 — 블록 뒤 개행(sB-1)이
|
|
984
|
-
// block과 prev를 자연스럽게 이어준다
|
|
985
|
-
[
|
|
986
|
-
{ pos: sB, ins: `${prev}\n` },
|
|
987
|
-
{ pos: pStart, del: sA - pStart },
|
|
988
|
-
];
|
|
989
|
-
delta = -(prev.length + 1);
|
|
990
|
-
}
|
|
991
|
-
else {
|
|
992
|
-
if (hi === lines.length - 1)
|
|
993
|
-
return false;
|
|
994
|
-
const next = lines[hi + 1];
|
|
995
|
-
const nextLast = hi + 1 === lines.length - 1;
|
|
996
|
-
ops = nextLast
|
|
997
|
-
? // 다음 줄이 끝줄 — 앞 개행 포함 "\nnext"를 통째로 지운다
|
|
998
|
-
[
|
|
999
|
-
{ pos: sB - 1, del: next.length + 1 },
|
|
1000
|
-
{ pos: sA, ins: `${next}\n` },
|
|
1001
|
-
]
|
|
1002
|
-
: [
|
|
1003
|
-
{ pos: sB, del: next.length + 1 },
|
|
1004
|
-
{ pos: sA, ins: `${next}\n` },
|
|
1005
|
-
];
|
|
1006
|
-
delta = next.length + 1;
|
|
1007
|
-
}
|
|
1008
|
-
const lens = applyCharOps(tr, block, blockPath, ops);
|
|
1009
|
-
tr.setSelection(textSelection(charPoint(block, blockPath, from + delta, lens), charPoint(block, blockPath, to + delta, lens)));
|
|
1010
|
-
dispatch();
|
|
1011
|
-
return true;
|
|
1012
|
-
};
|
|
1013
|
-
}
|
|
1014
|
-
/**
|
|
1015
|
-
* Mod-Shift-D — 선택이 걸친 줄들을 바로 아래에 복제
|
|
1016
|
-
* (Ace duplicateSelection 관례). 사본이 선택된다.
|
|
1017
|
-
*/
|
|
1018
|
-
export const duplicateCodeLines = ({ state, tr, dispatch }) => {
|
|
1019
|
-
const ctx = codeContext(state);
|
|
1020
|
-
if (!ctx)
|
|
1021
|
-
return false;
|
|
1022
|
-
const { blockPath, block, text, from, to } = ctx;
|
|
1023
|
-
const starts = lineStarts(text);
|
|
1024
|
-
const [lo, hi] = codeLineRange(text, from, to);
|
|
1025
|
-
const sA = starts[lo];
|
|
1026
|
-
// sB는 다음 줄 시작 — 줄 텍스트는 [sA, sB-1) (sB-1이 개행)
|
|
1027
|
-
const sB = hi + 1 < starts.length ? starts[hi + 1] : text.length;
|
|
1028
|
-
const tail = sB === text.length;
|
|
1029
|
-
const dup = text.slice(sA, tail ? sB : sB - 1);
|
|
1030
|
-
const ops = [{ pos: sB, ins: tail ? `\n${dup}` : `${dup}\n` }];
|
|
1031
|
-
const cs = tail ? sB + 1 : sB;
|
|
1032
|
-
const lens = applyCharOps(tr, block, blockPath, ops);
|
|
1033
|
-
tr.setSelection(textSelection(charPoint(block, blockPath, from - sA + cs, lens), charPoint(block, blockPath, to - sA + cs, lens)));
|
|
1034
|
-
dispatch();
|
|
1035
|
-
return true;
|
|
1036
|
-
};
|
|
1037
|
-
const BRACKET_OPEN = "([{";
|
|
1038
|
-
const BRACKET_CLOSE = ")]}";
|
|
1039
|
-
const BRACKET_PAIR = {
|
|
1040
|
-
"(": ")", "[": "]", "{": "}",
|
|
1041
|
-
")": "(", "]": "[", "}": "{",
|
|
1042
|
-
};
|
|
1043
|
-
/**
|
|
1044
|
-
* 캐럿 인접 괄호와 짝의 문자 오프셋 (Ace bracket matching 관례).
|
|
1045
|
-
* 캐럿 앞/뒤의 ()[]{}를 보고 같은 종류만 세는 깊이 스캔으로 짝을
|
|
1046
|
-
* 찾는다 — 문자열/주석 토큰 안쪽의 괄호는 코드로 세지 않는다.
|
|
1047
|
-
* 없으면 null.
|
|
1048
|
-
*/
|
|
1049
|
-
export function codeBracketAt(state) {
|
|
1050
|
-
const ctx = codeContext(state);
|
|
1051
|
-
if (!ctx || ctx.from !== ctx.to)
|
|
1052
|
-
return null;
|
|
1053
|
-
const { text, to: off } = ctx;
|
|
1054
|
-
const inStr = codeStringMask(state, ctx.block) ?? (() => false);
|
|
1055
|
-
let idx = -1;
|
|
1056
|
-
let dir = 0;
|
|
1057
|
-
if (off > 0 && BRACKET_CLOSE.includes(text[off - 1])) {
|
|
1058
|
-
idx = off - 1;
|
|
1059
|
-
dir = -1;
|
|
1060
|
-
}
|
|
1061
|
-
else if (off < text.length && BRACKET_OPEN.includes(text[off])) {
|
|
1062
|
-
idx = off;
|
|
1063
|
-
dir = 1;
|
|
1064
|
-
}
|
|
1065
|
-
else if (off > 0 && BRACKET_OPEN.includes(text[off - 1])) {
|
|
1066
|
-
idx = off - 1;
|
|
1067
|
-
dir = 1;
|
|
1068
|
-
}
|
|
1069
|
-
else if (off < text.length && BRACKET_CLOSE.includes(text[off])) {
|
|
1070
|
-
idx = off;
|
|
1071
|
-
dir = -1;
|
|
1072
|
-
}
|
|
1073
|
-
if (idx < 0 || inStr(idx))
|
|
1074
|
-
return null;
|
|
1075
|
-
const ch = text[idx];
|
|
1076
|
-
const want = BRACKET_PAIR[ch];
|
|
1077
|
-
let depth = 0;
|
|
1078
|
-
if (dir > 0) {
|
|
1079
|
-
for (let j = idx; j < text.length; j++) {
|
|
1080
|
-
if (inStr(j))
|
|
1081
|
-
continue;
|
|
1082
|
-
const c = text[j];
|
|
1083
|
-
if (c === ch)
|
|
1084
|
-
depth++;
|
|
1085
|
-
else if (c === want && --depth === 0)
|
|
1086
|
-
return { a: idx, b: j };
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
else {
|
|
1090
|
-
for (let j = idx; j >= 0; j--) {
|
|
1091
|
-
if (inStr(j))
|
|
1092
|
-
continue;
|
|
1093
|
-
const c = text[j];
|
|
1094
|
-
if (c === ch)
|
|
1095
|
-
depth++;
|
|
1096
|
-
else if (c === want && --depth === 0)
|
|
1097
|
-
return { a: j, b: idx };
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
return null;
|
|
1101
|
-
}
|
|
1102
105
|
// ── 뷰 입력 훅 — Ace behaviours 관례의 타이핑 동작 ────────────
|
|
1103
106
|
/** 자동 쌍 테이블 — 열기 문자 → 닫기 문자 */
|
|
1104
107
|
const CODE_PAIRS = {
|
|
@@ -1111,7 +114,6 @@ const CODE_PAIRS = {
|
|
|
1111
114
|
};
|
|
1112
115
|
/** 타입오버 대상 — 닫기/인용이 바로 뒤에 있으면 입력 대신 넘어간다 */
|
|
1113
116
|
const CODE_CLOSERS = ")]}'\"`";
|
|
1114
|
-
const isCodeBlock = (state, block) => state.schema.nodeSpec(block.type)?.code === true;
|
|
1115
117
|
const codeUIs = new WeakMap();
|
|
1116
118
|
/** 캐럿이 속한 코드블록의 pre — 코드 캐럿 아니면 null */
|
|
1117
119
|
function activePreEl(el, state) {
|
|
@@ -1147,7 +149,7 @@ function ensureCodeUI(el, editor) {
|
|
|
1147
149
|
};
|
|
1148
150
|
box.append(sel, mkBtn("정리", "format", "코드 서식 정리 (Alt+Shift+F) — JSON pretty-print·줄 끝 공백·빈 줄 정리"), mkBtn("⧉", "copy", "코드 복사"));
|
|
1149
151
|
(el.parentElement ?? el).appendChild(box);
|
|
1150
|
-
const ui = { box, sel };
|
|
152
|
+
const ui = { box, sel, observer: null };
|
|
1151
153
|
codeUIs.set(el, ui);
|
|
1152
154
|
// 버튼은 캐럿을 빼앗지 않는다 — select는 기본 동작(드롭다운)이
|
|
1153
155
|
// 필요해 mousedown을 먹지 않는다
|
|
@@ -1175,10 +177,11 @@ function ensureCodeUI(el, editor) {
|
|
|
1175
177
|
});
|
|
1176
178
|
// 레이아웃 변화로 위치가 어긋나면 다시 맞춘다
|
|
1177
179
|
if (typeof ResizeObserver !== "undefined") {
|
|
1178
|
-
new ResizeObserver(() => {
|
|
180
|
+
ui.observer = new ResizeObserver(() => {
|
|
1179
181
|
if (!ui.box.hidden)
|
|
1180
182
|
paintCodeOverlay(el, editor);
|
|
1181
|
-
})
|
|
183
|
+
});
|
|
184
|
+
ui.observer.observe(el);
|
|
1182
185
|
}
|
|
1183
186
|
return ui;
|
|
1184
187
|
}
|
|
@@ -1267,9 +270,12 @@ export const codeBlockFeature = defineFeature({
|
|
|
1267
270
|
// 코드블록 — ``` / ~~~ + 스페이스. ```js 처럼 언어 지정 가능
|
|
1268
271
|
// (Tiptap backtickInputRegex 관례 — 공백 트리거라 언어
|
|
1269
272
|
// 접미사를 칠 시간이 있다)
|
|
1270
|
-
blockRule(/^(?:```|~~~)([\w+-]*) $/, (m) => ({
|
|
273
|
+
blockRule(/^(?:```|~~~)([\w+-]*) $/, (m, block) => ({
|
|
1271
274
|
type: "codeBlock",
|
|
1272
275
|
attrs: { language: m[1] ?? "" },
|
|
276
|
+
// 코드는 마크를 받지 않는다 — toggleCodeBlock과 같은 규칙으로
|
|
277
|
+
// 기존 인라인 마크(대기 서식이 입혀진 fence 텍스트 등)를 벗긴다
|
|
278
|
+
content: (block.content ?? []).map((c) => ({ ...c, marks: undefined })),
|
|
1273
279
|
})),
|
|
1274
280
|
],
|
|
1275
281
|
input: {
|
|
@@ -1286,9 +292,7 @@ export const codeBlockFeature = defineFeature({
|
|
|
1286
292
|
if (!pointEq(ctx.anchor, ctx.focus)) {
|
|
1287
293
|
ctx.tr.delete({ anchor: ctx.anchor, focus: ctx.focus });
|
|
1288
294
|
}
|
|
1289
|
-
ctx.tr
|
|
1290
|
-
.insertText(data.text, ctx.anchor)
|
|
1291
|
-
.setMeta("paste", true);
|
|
295
|
+
ctx.tr.insertText(data.text, ctx.anchor).setMeta("paste", true);
|
|
1292
296
|
ctx.dispatch();
|
|
1293
297
|
}
|
|
1294
298
|
return true;
|
|
@@ -1361,9 +365,7 @@ export const codeBlockFeature = defineFeature({
|
|
|
1361
365
|
const caretOff = ctx.offsetOf(ctx.anchor);
|
|
1362
366
|
const lineStart = caretOff === 0 ? 0 : text.lastIndexOf("\n", caretOff - 1) + 1;
|
|
1363
367
|
const curLine = text.slice(lineStart, caretOff);
|
|
1364
|
-
const atEmptyTail = caretOff === text.length &&
|
|
1365
|
-
lineStart > 0 &&
|
|
1366
|
-
/^[ \t]*$/.test(curLine);
|
|
368
|
+
const atEmptyTail = caretOff === text.length && lineStart > 0 && /^[ \t]*$/.test(curLine);
|
|
1367
369
|
if (!text || atEmptyTail) {
|
|
1368
370
|
// 꼬리 빈 줄(개행 + 공백)을 지우고 블록 뒤에 본문 삽입
|
|
1369
371
|
if (atEmptyTail) {
|
|
@@ -1496,6 +498,14 @@ export const codeBlockFeature = defineFeature({
|
|
|
1496
498
|
}
|
|
1497
499
|
reg.set("me-bracket", new H(...ranges));
|
|
1498
500
|
},
|
|
501
|
+
destroyOverlay(el) {
|
|
502
|
+
// 크롬 바 + 옵저버 + 괄호 하이라이트를 정리하고 맵 엔트리를 지운다
|
|
503
|
+
const ui = codeUIs.get(el);
|
|
504
|
+
ui?.observer?.disconnect();
|
|
505
|
+
ui?.box.remove();
|
|
506
|
+
codeUIs.delete(el);
|
|
507
|
+
el.ownerDocument.defaultView?.CSS?.highlights?.delete("me-bracket");
|
|
508
|
+
},
|
|
1499
509
|
},
|
|
1500
510
|
});
|
|
1501
511
|
//# sourceMappingURL=code-block.js.map
|