@moda-editor/core 0.1.1

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.
Files changed (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/dist/commands.d.ts +78 -0
  4. package/dist/commands.d.ts.map +1 -0
  5. package/dist/commands.js +299 -0
  6. package/dist/commands.js.map +1 -0
  7. package/dist/conformance.d.ts +59 -0
  8. package/dist/conformance.d.ts.map +1 -0
  9. package/dist/conformance.js +1609 -0
  10. package/dist/conformance.js.map +1 -0
  11. package/dist/core.d.ts +37 -0
  12. package/dist/core.d.ts.map +1 -0
  13. package/dist/core.js +117 -0
  14. package/dist/core.js.map +1 -0
  15. package/dist/editor/editor.d.ts +104 -0
  16. package/dist/editor/editor.d.ts.map +1 -0
  17. package/dist/editor/editor.js +204 -0
  18. package/dist/editor/editor.js.map +1 -0
  19. package/dist/editor/extension.d.ts +170 -0
  20. package/dist/editor/extension.d.ts.map +1 -0
  21. package/dist/editor/extension.js +2 -0
  22. package/dist/editor/extension.js.map +1 -0
  23. package/dist/features/ace.d.ts +9 -0
  24. package/dist/features/ace.d.ts.map +1 -0
  25. package/dist/features/ace.js +144 -0
  26. package/dist/features/ace.js.map +1 -0
  27. package/dist/features/alignment.d.ts +9 -0
  28. package/dist/features/alignment.d.ts.map +1 -0
  29. package/dist/features/alignment.js +28 -0
  30. package/dist/features/alignment.js.map +1 -0
  31. package/dist/features/blockquote.d.ts +9 -0
  32. package/dist/features/blockquote.d.ts.map +1 -0
  33. package/dist/features/blockquote.js +65 -0
  34. package/dist/features/blockquote.js.map +1 -0
  35. package/dist/features/code-block.d.ts +77 -0
  36. package/dist/features/code-block.d.ts.map +1 -0
  37. package/dist/features/code-block.js +986 -0
  38. package/dist/features/code-block.js.map +1 -0
  39. package/dist/features/doc.d.ts +15 -0
  40. package/dist/features/doc.d.ts.map +1 -0
  41. package/dist/features/doc.js +79 -0
  42. package/dist/features/doc.js.map +1 -0
  43. package/dist/features/feature.d.ts +26 -0
  44. package/dist/features/feature.d.ts.map +1 -0
  45. package/dist/features/feature.js +8 -0
  46. package/dist/features/feature.js.map +1 -0
  47. package/dist/features/hard-break.d.ts +9 -0
  48. package/dist/features/hard-break.d.ts.map +1 -0
  49. package/dist/features/hard-break.js +48 -0
  50. package/dist/features/hard-break.js.map +1 -0
  51. package/dist/features/heading.d.ts +3 -0
  52. package/dist/features/heading.d.ts.map +1 -0
  53. package/dist/features/heading.js +31 -0
  54. package/dist/features/heading.js.map +1 -0
  55. package/dist/features/horizontal-rule.d.ts +9 -0
  56. package/dist/features/horizontal-rule.d.ts.map +1 -0
  57. package/dist/features/horizontal-rule.js +64 -0
  58. package/dist/features/horizontal-rule.js.map +1 -0
  59. package/dist/features/image.d.ts +12 -0
  60. package/dist/features/image.d.ts.map +1 -0
  61. package/dist/features/image.js +116 -0
  62. package/dist/features/image.js.map +1 -0
  63. package/dist/features/index.d.ts +31 -0
  64. package/dist/features/index.d.ts.map +1 -0
  65. package/dist/features/index.js +65 -0
  66. package/dist/features/index.js.map +1 -0
  67. package/dist/features/inline-style.d.ts +12 -0
  68. package/dist/features/inline-style.d.ts.map +1 -0
  69. package/dist/features/inline-style.js +126 -0
  70. package/dist/features/inline-style.js.map +1 -0
  71. package/dist/features/link.d.ts +29 -0
  72. package/dist/features/link.d.ts.map +1 -0
  73. package/dist/features/link.js +198 -0
  74. package/dist/features/link.js.map +1 -0
  75. package/dist/features/lists.d.ts +14 -0
  76. package/dist/features/lists.d.ts.map +1 -0
  77. package/dist/features/lists.js +178 -0
  78. package/dist/features/lists.js.map +1 -0
  79. package/dist/features/marks.d.ts +6 -0
  80. package/dist/features/marks.d.ts.map +1 -0
  81. package/dist/features/marks.js +68 -0
  82. package/dist/features/marks.js.map +1 -0
  83. package/dist/features/script.d.ts +2 -0
  84. package/dist/features/script.d.ts.map +1 -0
  85. package/dist/features/script.js +55 -0
  86. package/dist/features/script.js.map +1 -0
  87. package/dist/features/table.d.ts +20 -0
  88. package/dist/features/table.d.ts.map +1 -0
  89. package/dist/features/table.js +195 -0
  90. package/dist/features/table.js.map +1 -0
  91. package/dist/features/task-list.d.ts +10 -0
  92. package/dist/features/task-list.d.ts.map +1 -0
  93. package/dist/features/task-list.js +68 -0
  94. package/dist/features/task-list.js.map +1 -0
  95. package/dist/features/testkit.d.ts +32 -0
  96. package/dist/features/testkit.d.ts.map +1 -0
  97. package/dist/features/testkit.js +92 -0
  98. package/dist/features/testkit.js.map +1 -0
  99. package/dist/history.d.ts +40 -0
  100. package/dist/history.d.ts.map +1 -0
  101. package/dist/history.js +192 -0
  102. package/dist/history.js.map +1 -0
  103. package/dist/index.d.ts +39 -0
  104. package/dist/index.d.ts.map +1 -0
  105. package/dist/index.js +29 -0
  106. package/dist/index.js.map +1 -0
  107. package/dist/model/node.d.ts +47 -0
  108. package/dist/model/node.d.ts.map +1 -0
  109. package/dist/model/node.js +30 -0
  110. package/dist/model/node.js.map +1 -0
  111. package/dist/model/schema.d.ts +105 -0
  112. package/dist/model/schema.d.ts.map +1 -0
  113. package/dist/model/schema.js +46 -0
  114. package/dist/model/schema.js.map +1 -0
  115. package/dist/model/traverse.d.ts +36 -0
  116. package/dist/model/traverse.d.ts.map +1 -0
  117. package/dist/model/traverse.js +123 -0
  118. package/dist/model/traverse.js.map +1 -0
  119. package/dist/mount.d.ts +24 -0
  120. package/dist/mount.d.ts.map +1 -0
  121. package/dist/mount.js +45 -0
  122. package/dist/mount.js.map +1 -0
  123. package/dist/parse/html.d.ts +8 -0
  124. package/dist/parse/html.d.ts.map +1 -0
  125. package/dist/parse/html.js +247 -0
  126. package/dist/parse/html.js.map +1 -0
  127. package/dist/parse/sanitize.d.ts +20 -0
  128. package/dist/parse/sanitize.d.ts.map +1 -0
  129. package/dist/parse/sanitize.js +116 -0
  130. package/dist/parse/sanitize.js.map +1 -0
  131. package/dist/presets/highlight.d.ts +23 -0
  132. package/dist/presets/highlight.d.ts.map +1 -0
  133. package/dist/presets/highlight.js +170 -0
  134. package/dist/presets/highlight.js.map +1 -0
  135. package/dist/presets/starter.d.ts +14 -0
  136. package/dist/presets/starter.d.ts.map +1 -0
  137. package/dist/presets/starter.js +18 -0
  138. package/dist/presets/starter.js.map +1 -0
  139. package/dist/state/apply.d.ts +64 -0
  140. package/dist/state/apply.d.ts.map +1 -0
  141. package/dist/state/apply.js +528 -0
  142. package/dist/state/apply.js.map +1 -0
  143. package/dist/state/navigate.d.ts +5 -0
  144. package/dist/state/navigate.d.ts.map +1 -0
  145. package/dist/state/navigate.js +81 -0
  146. package/dist/state/navigate.js.map +1 -0
  147. package/dist/state/selection.d.ts +44 -0
  148. package/dist/state/selection.d.ts.map +1 -0
  149. package/dist/state/selection.js +63 -0
  150. package/dist/state/selection.js.map +1 -0
  151. package/dist/state/state.d.ts +39 -0
  152. package/dist/state/state.d.ts.map +1 -0
  153. package/dist/state/state.js +123 -0
  154. package/dist/state/state.js.map +1 -0
  155. package/dist/state/transaction.d.ts +234 -0
  156. package/dist/state/transaction.d.ts.map +1 -0
  157. package/dist/state/transaction.js +988 -0
  158. package/dist/state/transaction.js.map +1 -0
  159. package/dist/styles.css +948 -0
  160. package/dist/types.d.ts +44 -0
  161. package/dist/types.d.ts.map +1 -0
  162. package/dist/types.js +8 -0
  163. package/dist/types.js.map +1 -0
  164. package/dist/view/dom.d.ts +26 -0
  165. package/dist/view/dom.d.ts.map +1 -0
  166. package/dist/view/dom.js +249 -0
  167. package/dist/view/dom.js.map +1 -0
  168. package/dist/view/input.d.ts +45 -0
  169. package/dist/view/input.d.ts.map +1 -0
  170. package/dist/view/input.js +901 -0
  171. package/dist/view/input.js.map +1 -0
  172. package/dist/view/mount.d.ts +26 -0
  173. package/dist/view/mount.d.ts.map +1 -0
  174. package/dist/view/mount.js +119 -0
  175. package/dist/view/mount.js.map +1 -0
  176. package/dist/view/render.d.ts +64 -0
  177. package/dist/view/render.d.ts.map +1 -0
  178. package/dist/view/render.js +203 -0
  179. package/dist/view/render.js.map +1 -0
  180. package/dist/view/serialize.d.ts +4 -0
  181. package/dist/view/serialize.d.ts.map +1 -0
  182. package/dist/view/serialize.js +37 -0
  183. package/dist/view/serialize.js.map +1 -0
  184. package/package.json +44 -0
@@ -0,0 +1,986 @@
1
+ import { firstTextPath, nodeAt } from "../model/traverse.js";
2
+ import { leafPoint } from "../state/apply.js";
3
+ import { normalizedRange, pointCmp, pointEq, textSelection, } from "../state/selection.js";
4
+ import { highlightCode } from "../presets/highlight.js";
5
+ import { PATH_ATTR } from "../view/dom.js";
6
+ import { defineFeature } from "./feature.js";
7
+ import { blockRule, blockText, blockTextPoint, caretBlockPath, } from "../commands.js";
8
+ /**
9
+ * 코드블록 — ` ``` ` 입력 규칙, 구문 하이라이트, Ace/VS Code 관례의
10
+ * 코드 편집 명령(들여쓰기·주석 토글·줄 이동/복제·괄호 매칭·화살표
11
+ * 탈출). 코드 텍스트는 마크 없는 순수 텍스트다.
12
+ */
13
+ // ── 블록 변환 ─────────────────────────────────────────────
14
+ /**
15
+ * 코드블록 토글 — 안이면 본문으로, 밖이면 선택을 코드블록으로.
16
+ * 선택이 여러 최상위 텍스트블록을 걸치면 전부 하나의 코드블록으로
17
+ * 합친다 — 블록 사이는 \n으로 잇는다(코드블록은 줄바꿈을 텍스트에
18
+ * 내장). 마크는 벗긴다 (코드는 항상 순수 텍스트).
19
+ */
20
+ export const toggleCodeBlock = ({ state, tr, dispatch }) => {
21
+ const sel = state.selection;
22
+ const blockPath = caretBlockPath(state);
23
+ if (!blockPath || sel.type !== "text")
24
+ return false;
25
+ const block = nodeAt(state.doc, blockPath);
26
+ if (!block)
27
+ return false;
28
+ if (block.type === "codeBlock") {
29
+ tr.replaceNode(blockPath, { ...block, type: "paragraph" });
30
+ dispatch();
31
+ return true;
32
+ }
33
+ const spec = state.schema.nodeSpec(block.type);
34
+ if (!spec || !/inline/.test(spec.content ?? ""))
35
+ return false;
36
+ // 멀티블록 선택 — 최상위 블록 인덱스 범위를 통째로 하나로 합친다
37
+ const range = normalizedRange(sel);
38
+ const [a, b] = [range.anchor.path[0], range.focus.path[0]];
39
+ if (b > a) {
40
+ const blocks = state.doc.content.slice(a, b + 1);
41
+ const textSpec = (n) => /inline/.test(state.schema.nodeSpec(n.type)?.content ?? "");
42
+ if (!blocks.every(textSpec))
43
+ return false;
44
+ const merged = {
45
+ type: "codeBlock",
46
+ content: [
47
+ { type: "text", text: blocks.map(blockText).join("\n") },
48
+ ],
49
+ };
50
+ tr.delete({
51
+ anchor: { path: [], offset: a },
52
+ focus: { path: [], offset: b + 1 },
53
+ });
54
+ tr.insertNode({ path: [], offset: a }, merged);
55
+ tr.setSelection(textSelection({ path: [a, 0], offset: merged.content?.[0]?.text?.length ?? 0 }));
56
+ dispatch();
57
+ return true;
58
+ }
59
+ tr.replaceNode(blockPath, {
60
+ ...block,
61
+ type: "codeBlock",
62
+ content: (block.content ?? []).map((c) => ({ ...c, marks: undefined })),
63
+ });
64
+ dispatch();
65
+ return true;
66
+ };
67
+ /** 캐럿 코드블록의 language attr 설정 — data-language 라벨로 렌더 */
68
+ export function setCodeLanguage(language) {
69
+ return ({ state, tr, dispatch }) => {
70
+ const blockPath = caretBlockPath(state);
71
+ const block = blockPath ? nodeAt(state.doc, blockPath) : undefined;
72
+ if (!blockPath || block?.type !== "codeBlock")
73
+ return false;
74
+ tr.setNodeAttr(blockPath, "language", language);
75
+ dispatch();
76
+ return true;
77
+ };
78
+ }
79
+ /**
80
+ * 선택과 겹치는 최상위 `code: true` 블록별 문자 좌표를 돌려준다.
81
+ * 컨테이너 경계 포인트(전체 선택의 {path:[],offset:k} 등)는 텍스트
82
+ * 리프로 내려 해석하고, 블록 밖으로 나간 엔드포인트는 그 블록의
83
+ * 경계로 클램프한다 — Mod-A 처럼 여러 블록을 걸친 선택에서도
84
+ * 코드 블록 구간만 정확히 잘라낸다.
85
+ */
86
+ function codeSelectionContexts(state) {
87
+ const sel = state.selection;
88
+ if (sel.type !== "text")
89
+ return [];
90
+ const r = normalizedRange(sel);
91
+ const aLeaf = leafPoint(state.doc, r.anchor);
92
+ const fLeaf = leafPoint(state.doc, r.focus);
93
+ const loI = aLeaf.path[0];
94
+ const hiI = fLeaf.path[0];
95
+ if (loI == null || hiI == null)
96
+ return [];
97
+ const out = [];
98
+ for (let i = loI; i <= hiI && i < (state.doc.content?.length ?? 0); i++) {
99
+ const block = state.doc.content[i];
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
+ });
120
+ }
121
+ return out;
122
+ }
123
+ /**
124
+ * 캐럿(포커스)이 속한 코드 컨텍스트 — 포커스 리프가 그 블록 안에
125
+ * 있어야 한다 (화살표 탈출·들여쓰기 등 캐럿 명령용). 선택만 걸치고
126
+ * 포커스는 비코드 블록에 있으면 null.
127
+ */
128
+ function codeContext(state) {
129
+ const sel = state.selection;
130
+ if (sel.type !== "text")
131
+ return null;
132
+ const r = normalizedRange(sel);
133
+ const fLeaf = leafPoint(state.doc, r.focus);
134
+ // 리프가 텍스트면 부모가 블록, 컨테이너(빈 코드블록 등)면 자기 자신
135
+ const fBlock = nodeAt(state.doc, fLeaf.path)?.type === "text"
136
+ ? fLeaf.path.slice(0, -1)
137
+ : fLeaf.path;
138
+ for (const ctx of codeSelectionContexts(state)) {
139
+ if (ctx.blockPath.length === fBlock.length &&
140
+ ctx.blockPath.every((v, i) => v === fBlock[i])) {
141
+ return ctx;
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+ /** 블록 포인트 → 병합 문자 오프셋. 블록 밖 포인트면 null */
147
+ function charOffset(block, blockPath, p) {
148
+ const kids = block.content ?? [];
149
+ const rel = p.path.slice(blockPath.length);
150
+ if (!p.path.slice(0, blockPath.length).every((v, i) => v === blockPath[i])) {
151
+ return null;
152
+ }
153
+ if (rel.length === 0) {
154
+ // 컨테이너 포인트 — 앞쪽 자식들의 텍스트 합
155
+ let sum = 0;
156
+ for (let i = 0; i < Math.min(p.offset, kids.length); i++) {
157
+ sum += kids[i].text?.length ?? 0;
158
+ }
159
+ return sum;
160
+ }
161
+ if (rel.length !== 1)
162
+ return null;
163
+ let sum = p.offset;
164
+ for (let i = 0; i < rel[0] && i < kids.length; i++) {
165
+ sum += kids[i].text?.length ?? 0;
166
+ }
167
+ return sum;
168
+ }
169
+ /** 병합 문자 오프셋 → 텍스트 포인트 (기존 자식 경계로 해석) */
170
+ function charPoint(block, blockPath, off, lens) {
171
+ const kids = block.content ?? [];
172
+ let rest = off;
173
+ for (let i = 0; i < kids.length; i++) {
174
+ const t = lens?.[i] ?? kids[i].text?.length ?? 0;
175
+ if (rest <= t || i === kids.length - 1) {
176
+ return { path: [...blockPath, i], offset: Math.min(rest, t) };
177
+ }
178
+ rest -= t;
179
+ }
180
+ return { path: [...blockPath, 0], offset: 0 };
181
+ }
182
+ /**
183
+ * 문자열/주석 토큰 안의 문자 오프셋 판정 — spec.codeHighlighter로
184
+ * 토큰화해 그 구간에 있으면 true를 돌린다 (Ace가 페어링·매칭을
185
+ * 끌 때 쓰는 isCodeLike 관례). 하이라이터가 없으면 null.
186
+ */
187
+ export function codeStringMask(state, block) {
188
+ const hl = state.schema.nodeSpec(block.type)?.codeHighlighter;
189
+ if (!hl)
190
+ return null;
191
+ const text = (block.content ?? []).map((c) => c.text ?? "").join("");
192
+ const lang = block.attrs?.["language"] ?? null;
193
+ const spans = [];
194
+ let i = 0;
195
+ for (const t of hl(text, lang)) {
196
+ const end = i + t.text.length;
197
+ if (t.kind === "string" || t.kind === "comment") {
198
+ const first = t.text[0] ?? "";
199
+ // 열린 토큰(//·# 라인 주석, 닫히지 않은 문자열/블록 주석)은
200
+ // 끝 경계도 안쪽으로 본다 — 그 자리의 입력이 토큰을 이어받는다
201
+ const open = first === "#" ||
202
+ (first === "/" && !t.text.endsWith("*/")) ||
203
+ (first !== "/" &&
204
+ first !== "#" &&
205
+ !(t.text.length > 1 && t.text.endsWith(first)));
206
+ spans.push([i, open ? end + 1 : end]);
207
+ }
208
+ i = end;
209
+ }
210
+ return (off) => spans.some(([a, b]) => off >= a && off < b);
211
+ }
212
+ /** 문자열의 줄 시작 오프셋 목록 (0과 각 '\n' 직후) */
213
+ function lineStarts(text) {
214
+ const out = [0];
215
+ for (let i = 0; i < text.length; i++) {
216
+ if (text[i] === "\n")
217
+ out.push(i + 1);
218
+ }
219
+ return out;
220
+ }
221
+ const CODE_TAB = "\t";
222
+ const CODE_TAB_SIZE = 4;
223
+ // ── 들여쓰기 (VS Code 관례) ───────────────────────────────
224
+ /**
225
+ * Tab — 코드블록 들여쓰기. collapsed면 캐럿에 \t 삽입,
226
+ * 범위 선택이면 걸친 줄 전부의 앞에 \t를 단다 (VS Code 관례).
227
+ * 코드블록 밖이면 false — 리스트/표 키바인딩으로 폴스루.
228
+ */
229
+ export const indentCode = ({ state, tr, dispatch }) => {
230
+ const ctx = codeContext(state);
231
+ if (!ctx)
232
+ return false;
233
+ const { blockPath, block, text, from, to } = ctx;
234
+ if (from === to) {
235
+ const at = charPoint(block, blockPath, from);
236
+ tr.insertText(CODE_TAB, at);
237
+ // 삽입 후 길이로 해석해야 from+1이 삽입 전 길이에 클램프되지 않는다
238
+ const kidIdx = at.path[at.path.length - 1];
239
+ const lens = (block.content ?? []).map((k, i) => (k.text?.length ?? 0) + (i === kidIdx ? 1 : 0));
240
+ tr.setSelection(textSelection(charPoint(block, blockPath, from + 1, lens)));
241
+ dispatch();
242
+ return true;
243
+ }
244
+ // 걸친 줄들의 시작 — 'to'가 정확히 줄 시작이면 그 줄은 제외
245
+ const starts = lineStarts(text);
246
+ const start0 = Math.max(...starts.filter((s) => s <= from));
247
+ const covered = starts.filter((s) => s >= start0 && s < to);
248
+ if (!covered.length)
249
+ return false;
250
+ // 뒤쪽 줄부터 삽입해야 앞쪽 좌표가 안 밀린다
251
+ const kids = block.content ?? [];
252
+ const extra = kids.map(() => 0);
253
+ for (const s of [...covered].sort((a, b) => b - a)) {
254
+ const p = charPoint(block, blockPath, s);
255
+ tr.insertText(CODE_TAB, p);
256
+ extra[p.path[p.path.length - 1]] += 1;
257
+ }
258
+ const lens = kids.map((k, i) => (k.text?.length ?? 0) + extra[i]);
259
+ const shift = (off) => off + covered.filter((s) => s <= off).length;
260
+ tr.setSelection(textSelection(charPoint(block, blockPath, shift(from), lens), charPoint(block, blockPath, shift(to), lens)));
261
+ dispatch();
262
+ return true;
263
+ };
264
+ /**
265
+ * Shift-Tab — 코드블록 내어쓰기. 걸친 각 줄의 앞에서 \t 하나 또는
266
+ * 스페이스 최대 tabSize개를 지운다. 코드블록 밖이면 false로 폴스루.
267
+ */
268
+ export const dedentCode = ({ state, tr, dispatch }) => {
269
+ const ctx = codeContext(state);
270
+ if (!ctx)
271
+ return false;
272
+ const { blockPath, block, text, from, to } = ctx;
273
+ const starts = lineStarts(text);
274
+ const start0 = Math.max(...starts.filter((s) => s <= from));
275
+ // 각 줄에서 지울 들여쓰기 — \t 하나 또는 앞쪽 연속 스페이스 최대 tabSize개
276
+ const edits = starts
277
+ .filter((s) => s >= start0 && (from === to ? s <= from : s < to))
278
+ .map((s) => {
279
+ let n = 0;
280
+ if (text[s] === CODE_TAB)
281
+ n = 1;
282
+ else
283
+ while (n < CODE_TAB_SIZE && text[s + n] === " ")
284
+ n++;
285
+ return { s, n };
286
+ })
287
+ .filter((e) => e.n > 0);
288
+ // 코드블록 안인데 지울 들여쓰기가 없으면 그냥 삼킨다 — false로
289
+ // 폴스루하면 브라우저가 포커스를 에디터 밖으로 옮긴다 (Ace/VS Code는 no-op)
290
+ if (!edits.length)
291
+ return true;
292
+ const kids = block.content ?? [];
293
+ const extra = kids.map(() => 0);
294
+ for (const { s, n } of [...edits].sort((a, b) => b.s - a.s)) {
295
+ const a = charPoint(block, blockPath, s);
296
+ tr.delete({
297
+ anchor: a,
298
+ focus: { path: a.path, offset: a.offset + n },
299
+ });
300
+ extra[a.path[a.path.length - 1]] -= n;
301
+ }
302
+ const lens = kids.map((k, i) => (k.text?.length ?? 0) + extra[i]);
303
+ // 지워진 들여쓰기만큼 좌표를 당긴다 — 줄 시작(s === off) 위치의
304
+ // 캐럿은 들여쓰기 앞에 머문다 (s < off 기준)
305
+ const shift = (off) => Math.max(0, off - edits.reduce((d, e) => d + (e.s < off ? e.n : 0), 0));
306
+ tr.setSelection(textSelection(charPoint(block, blockPath, shift(from), lens), charPoint(block, blockPath, shift(to), lens)));
307
+ dispatch();
308
+ return true;
309
+ };
310
+ /**
311
+ * ArrowDown/ArrowUp — 코드블록 가장자리 줄 탈출 (Tiptap
312
+ * exitOnArrowDown/Up 관례). 마지막 줄에서 ArrowDown을 누르면 블록
313
+ * 뒤로, 첫 줄에서 ArrowUp이면 블록 앞으로 나간다 — 이웃 블록이
314
+ * 없으면 빈 문단을 만든다. Notion의 코드블록 화살표 탈출과 같다.
315
+ */
316
+ export function exitCodeArrow(direction) {
317
+ return ({ state, tr, dispatch }) => {
318
+ const ctx = codeContext(state);
319
+ if (!ctx || ctx.from !== ctx.to)
320
+ return false;
321
+ const { blockPath, text, from } = ctx;
322
+ const onEdge = direction === "down"
323
+ ? !text.slice(from).includes("\n")
324
+ : !text.slice(0, from).includes("\n");
325
+ if (!onEdge)
326
+ return false;
327
+ const parentPath = blockPath.slice(0, -1);
328
+ const bi = blockPath[blockPath.length - 1];
329
+ const si = direction === "down" ? bi + 1 : bi - 1;
330
+ const sib = si >= 0 ? nodeAt(state.doc, [...parentPath, si]) : undefined;
331
+ if (sib) {
332
+ const tp = direction === "down" ? firstTextPath(sib) : null;
333
+ const sp = tp
334
+ ? [...parentPath, si, ...tp]
335
+ : [...parentPath, si];
336
+ tr.setSelection(textSelection({ path: sp, offset: 0 }));
337
+ dispatch();
338
+ return true;
339
+ }
340
+ // 이웃이 없으면 빈 문단을 만들어 탈출 — bi=0에서 ArrowUp이면
341
+ // 코드블록 앞에, 끝에서 ArrowDown이면 뒤에 놓인다
342
+ const at = { path: parentPath, offset: direction === "down" ? bi + 1 : bi };
343
+ tr.insertNode(at, { type: "paragraph", content: [] });
344
+ tr.setSelection(textSelection({ path: [...at.path, at.offset], offset: 0 }));
345
+ dispatch();
346
+ return true;
347
+ };
348
+ }
349
+ /** 캐럿 코드블록의 텍스트 — 복사 버튼 등 UI용. 밖이면 null */
350
+ export function codeTextAt(state) {
351
+ const ctx = codeContext(state);
352
+ return ctx ? ctx.text : null;
353
+ }
354
+ /** 코드블록 안 캐럿의 줄/열 (Ace 상태바 Ln:Col 관례). 밖이면 null */
355
+ export function codeCaret(state) {
356
+ const ctx = codeContext(state);
357
+ if (!ctx)
358
+ return null;
359
+ const before = ctx.text.slice(0, ctx.to);
360
+ const nl = before.lastIndexOf("\n");
361
+ return { line: before.split("\n").length, col: before.length - nl };
362
+ }
363
+ // ── 코드 편집 명령 — 주석 토글·줄 이동/복제·괄호 매칭 (Ace 관례) ──
364
+ /** 선택이 걸친 줄 범위 [lo, hi] — to가 정확히 줄 시작이면 그 줄은 제외 */
365
+ function codeLineRange(text, from, to) {
366
+ const starts = lineStarts(text);
367
+ const lo = starts.filter((s) => s <= from).length - 1;
368
+ let hi = starts.filter((s) => s <= to).length - 1;
369
+ if (to > from && starts[hi] === to)
370
+ hi--;
371
+ return [Math.max(0, lo), Math.max(0, hi)];
372
+ }
373
+ /**
374
+ * 문자 오프셋 편집 목록(삽입/삭제)을 블록에 적용하고 새 doc 기준
375
+ * 텍스트 자식별 길이 배열을 돌려준다. ops는 겹치지 않는 원본 좌표 —
376
+ * 뒤쪽(큰 pos)부터 적용해야 앞쪽 좌표가 안 밀린다.
377
+ */
378
+ function applyCharOps(tr, block, blockPath, ops) {
379
+ const kids = block.content ?? [];
380
+ const extra = kids.map(() => 0);
381
+ const ends = [];
382
+ let acc = 0;
383
+ for (const k of kids) {
384
+ acc += k.text?.length ?? 0;
385
+ ends.push(acc);
386
+ }
387
+ for (const op of [...ops].sort((a, b) => b.pos - a.pos)) {
388
+ if (op.del) {
389
+ tr.delete({
390
+ anchor: charPoint(block, blockPath, op.pos),
391
+ focus: charPoint(block, blockPath, op.pos + op.del),
392
+ });
393
+ for (let i = 0; i < kids.length; i++) {
394
+ const ks = i === 0 ? 0 : ends[i - 1];
395
+ const ov = Math.min(op.pos + op.del, ends[i]) - Math.max(op.pos, ks);
396
+ if (ov > 0)
397
+ extra[i] -= ov;
398
+ }
399
+ }
400
+ if (op.ins) {
401
+ const at = charPoint(block, blockPath, op.pos);
402
+ tr.insertText(op.ins, at);
403
+ extra[at.path[at.path.length - 1]] += op.ins.length;
404
+ }
405
+ }
406
+ return kids.map((k, i) => (k.text?.length ?? 0) + extra[i]);
407
+ }
408
+ /** ops 기준으로 문자 오프셋을 새 좌표로 옮긴다 */
409
+ function shiftCharOff(ops, off) {
410
+ let v = off;
411
+ for (const op of [...ops].sort((a, b) => a.pos - b.pos)) {
412
+ if (op.pos > off)
413
+ break;
414
+ const ins = op.ins?.length ?? 0;
415
+ const del = op.del ?? 0;
416
+ if (op.pos + del <= off)
417
+ v += ins - del;
418
+ else
419
+ v = op.pos + ins;
420
+ }
421
+ return Math.max(0, v);
422
+ }
423
+ /** 언어 → 주석 문법. 문자열=줄 주석, 쌍=블록 래핑 (Ace mode 주석 관례) */
424
+ const CODE_COMMENT = {
425
+ py: "#", python: "#", rb: "#", ruby: "#", pl: "#", perl: "#",
426
+ sh: "#", bash: "#", zsh: "#", shell: "#", yaml: "#", yml: "#",
427
+ toml: "#", ini: "#", dockerfile: "#", makefile: "#", make: "#",
428
+ r: "#", coffee: "#", ps1: "#", powershell: "#", nim: "#",
429
+ sql: "--", lua: "--", haskell: "--", hs: "--", ada: "--",
430
+ css: ["/*", "*/"], scss: ["/*", "*/"], less: ["/*", "*/"],
431
+ html: ["<!--", "-->"], xml: ["<!--", "-->"], svg: ["<!--", "-->"],
432
+ md: ["<!--", "-->"], markdown: ["<!--", "-->"],
433
+ };
434
+ /**
435
+ * Mod-/ — 코드블록 줄 주석 토글 (Ace toggleCommentLines 관례).
436
+ * 걸친 줄이 전부 주석이면 해제, 아니면 주석을 단다 — 마커는
437
+ * 선행 공백 뒤에 붙어 들여쓰기가 보존된다 (VS Code 관례).
438
+ * 블록 주석 언어(css/html 등)는 줄마다 블록 마커로 감싼다.
439
+ */
440
+ export const toggleCodeComment = ({ state, tr, dispatch }) => {
441
+ // 선택과 겹치는 모든 코드 블록에 적용 — 전체 선택(Mod-A)이 여러
442
+ // 블록을 걸쳐도 코드 구간을 빠뜨리지 않는다 (VS Code 관례)
443
+ const ctxs = codeSelectionContexts(state);
444
+ if (!ctxs.length)
445
+ return false;
446
+ const parts = ctxs.map((ctx) => {
447
+ const lang = String(ctx.block.attrs?.["language"] ?? "").toLowerCase();
448
+ const lines = ctx.text.split("\n");
449
+ const [lo, hi] = codeLineRange(ctx.text, ctx.from, ctx.to);
450
+ return {
451
+ ctx,
452
+ syn: CODE_COMMENT[lang] ?? "//",
453
+ lines,
454
+ starts: lineStarts(ctx.text),
455
+ indent: (i) => lines[i].match(/^[ \t]*/)[0].length,
456
+ idx: Array.from({ length: hi - lo + 1 }, (_, k) => lo + k).filter((i) => lines[i].trim().length > 0),
457
+ };
458
+ });
459
+ if (!parts.some((p) => p.idx.length))
460
+ return false;
461
+ // 전부 주석이면 해제, 하나라도 아니면 주석 (블록을 넘나드는 선택도
462
+ // 같은 규칙 — VS Code는 선택 전체 기준으로 토글한다)
463
+ const commentedOf = (p, i) => {
464
+ const t = p.lines[i].slice(p.indent(i));
465
+ return typeof p.syn === "string"
466
+ ? t.startsWith(p.syn)
467
+ : t.trimEnd().startsWith(p.syn[0]) &&
468
+ t.trimEnd().endsWith(p.syn[1]) &&
469
+ t.trimEnd().length > p.syn[0].length + p.syn[1].length;
470
+ };
471
+ const allCommented = parts.every((p) => p.idx.every((i) => commentedOf(p, i)));
472
+ const edits = parts.map((p) => {
473
+ const { ctx, syn, lines, starts, idx, indent } = p;
474
+ const ops = [];
475
+ if (typeof syn === "string") {
476
+ if (allCommented) {
477
+ for (const i of idx) {
478
+ if (!commentedOf(p, i))
479
+ continue;
480
+ let n = syn.length;
481
+ if (lines[i][indent(i) + syn.length] === " ")
482
+ n += 1;
483
+ ops.push({ pos: starts[i] + indent(i), del: n });
484
+ }
485
+ }
486
+ else {
487
+ for (const i of idx) {
488
+ ops.push({ pos: starts[i] + indent(i), ins: `${syn} ` });
489
+ }
490
+ }
491
+ }
492
+ else {
493
+ const [o, c] = syn;
494
+ if (allCommented) {
495
+ for (const i of idx) {
496
+ if (!commentedOf(p, i))
497
+ continue;
498
+ const bp = starts[i] + indent(i);
499
+ const t = lines[i].slice(indent(i));
500
+ let open = o.length;
501
+ if (t[o.length] === " ")
502
+ open += 1;
503
+ const te = t.trimEnd().length;
504
+ let cs = te - c.length;
505
+ if (t[cs - 1] === " ")
506
+ cs -= 1;
507
+ ops.push({ pos: bp, del: open });
508
+ ops.push({ pos: bp + cs, del: te - cs });
509
+ }
510
+ }
511
+ else {
512
+ for (const i of idx) {
513
+ const bp = starts[i] + indent(i);
514
+ const te = lines[i].trimEnd().length;
515
+ ops.push({ pos: bp, ins: `${o} ` });
516
+ ops.push({ pos: bp + te, ins: ` ${c}` });
517
+ }
518
+ }
519
+ }
520
+ return ops;
521
+ });
522
+ const lens = parts.map((p, i) => applyCharOps(tr, p.ctx.block, p.ctx.blockPath, edits[i]));
523
+ // 셀렉션 복원 — 코드블록 안 엔드포인트는 삽입/삭제만큼 옮기고,
524
+ // 바깥(전체 선택의 비코드 쪽) 엔드포인트는 원래 위치를 유지한다
525
+ if (state.selection.type !== "text")
526
+ return false;
527
+ const selR = normalizedRange(state.selection);
528
+ const remap = (leaf) => {
529
+ const bi = leaf.path[0];
530
+ const pi = parts.findIndex((p) => p.ctx.blockPath[0] === bi);
531
+ if (pi < 0)
532
+ return leaf;
533
+ const p = parts[pi];
534
+ const off = charOffset(p.ctx.block, p.ctx.blockPath, leaf);
535
+ if (off == null)
536
+ return leaf;
537
+ return charPoint(p.ctx.block, p.ctx.blockPath, shiftCharOff(edits[pi], off), lens[pi]);
538
+ };
539
+ tr.setSelection(textSelection(remap(leafPoint(state.doc, selR.anchor)), remap(leafPoint(state.doc, selR.focus))));
540
+ dispatch();
541
+ return true;
542
+ };
543
+ /**
544
+ * Alt-↑/↓ — 선택이 걸친 줄들을 위/아래 한 줄 이동 (Ace movelines·
545
+ * VS Code 관례). 첫/마지막 줄 가장자리에선 false로 폴스루한다.
546
+ */
547
+ export function moveCodeLines(direction) {
548
+ return ({ state, tr, dispatch }) => {
549
+ const ctx = codeContext(state);
550
+ if (!ctx)
551
+ return false;
552
+ const { blockPath, block, text, from, to } = ctx;
553
+ const lines = text.split("\n");
554
+ const starts = lineStarts(text);
555
+ const [lo, hi] = codeLineRange(text, from, to);
556
+ const sA = starts[lo];
557
+ // sB는 다음 줄 시작 — 블록과 사이의 개행은 sB-1에 있다
558
+ const sB = hi + 1 < lines.length ? starts[hi + 1] : text.length;
559
+ let ops;
560
+ let delta;
561
+ if (direction === "up") {
562
+ if (lo === 0)
563
+ return false;
564
+ const prev = lines[lo - 1];
565
+ const pStart = starts[lo - 1];
566
+ ops =
567
+ sB === text.length
568
+ ? // 블록이 마지막 줄 — "block\nprev"가 되도록 끝에 붙인다
569
+ [
570
+ { pos: sB, ins: `\n${prev}` },
571
+ { pos: pStart, del: sA - pStart },
572
+ ]
573
+ : // 다음 줄 시작(sB)에 "prev\n"을 끼운다 — 블록 뒤 개행(sB-1)이
574
+ // block과 prev를 자연스럽게 이어준다
575
+ [
576
+ { pos: sB, ins: `${prev}\n` },
577
+ { pos: pStart, del: sA - pStart },
578
+ ];
579
+ delta = -(prev.length + 1);
580
+ }
581
+ else {
582
+ if (hi === lines.length - 1)
583
+ return false;
584
+ const next = lines[hi + 1];
585
+ const nextLast = hi + 1 === lines.length - 1;
586
+ ops = nextLast
587
+ ? // 다음 줄이 끝줄 — 앞 개행 포함 "\nnext"를 통째로 지운다
588
+ [
589
+ { pos: sB - 1, del: next.length + 1 },
590
+ { pos: sA, ins: `${next}\n` },
591
+ ]
592
+ : [
593
+ { pos: sB, del: next.length + 1 },
594
+ { pos: sA, ins: `${next}\n` },
595
+ ];
596
+ delta = next.length + 1;
597
+ }
598
+ const lens = applyCharOps(tr, block, blockPath, ops);
599
+ tr.setSelection(textSelection(charPoint(block, blockPath, from + delta, lens), charPoint(block, blockPath, to + delta, lens)));
600
+ dispatch();
601
+ return true;
602
+ };
603
+ }
604
+ /**
605
+ * Mod-Shift-D — 선택이 걸친 줄들을 바로 아래에 복제
606
+ * (Ace duplicateSelection 관례). 사본이 선택된다.
607
+ */
608
+ export const duplicateCodeLines = ({ state, tr, dispatch }) => {
609
+ const ctx = codeContext(state);
610
+ if (!ctx)
611
+ return false;
612
+ const { blockPath, block, text, from, to } = ctx;
613
+ const starts = lineStarts(text);
614
+ const [lo, hi] = codeLineRange(text, from, to);
615
+ const sA = starts[lo];
616
+ // sB는 다음 줄 시작 — 줄 텍스트는 [sA, sB-1) (sB-1이 개행)
617
+ const sB = hi + 1 < starts.length ? starts[hi + 1] : text.length;
618
+ const tail = sB === text.length;
619
+ const dup = text.slice(sA, tail ? sB : sB - 1);
620
+ const ops = [{ pos: sB, ins: tail ? `\n${dup}` : `${dup}\n` }];
621
+ const cs = tail ? sB + 1 : sB;
622
+ const lens = applyCharOps(tr, block, blockPath, ops);
623
+ tr.setSelection(textSelection(charPoint(block, blockPath, from - sA + cs, lens), charPoint(block, blockPath, to - sA + cs, lens)));
624
+ dispatch();
625
+ return true;
626
+ };
627
+ const BRACKET_OPEN = "([{";
628
+ const BRACKET_CLOSE = ")]}";
629
+ const BRACKET_PAIR = {
630
+ "(": ")", "[": "]", "{": "}",
631
+ ")": "(", "]": "[", "}": "{",
632
+ };
633
+ /**
634
+ * 캐럿 인접 괄호와 짝의 문자 오프셋 (Ace bracket matching 관례).
635
+ * 캐럿 앞/뒤의 ()[]{}를 보고 같은 종류만 세는 깊이 스캔으로 짝을
636
+ * 찾는다 — 문자열/주석 토큰 안쪽의 괄호는 코드로 세지 않는다.
637
+ * 없으면 null.
638
+ */
639
+ export function codeBracketAt(state) {
640
+ const ctx = codeContext(state);
641
+ if (!ctx || ctx.from !== ctx.to)
642
+ return null;
643
+ const { text, to: off } = ctx;
644
+ const inStr = codeStringMask(state, ctx.block) ?? (() => false);
645
+ let idx = -1;
646
+ let dir = 0;
647
+ if (off > 0 && BRACKET_CLOSE.includes(text[off - 1])) {
648
+ idx = off - 1;
649
+ dir = -1;
650
+ }
651
+ else if (off < text.length && BRACKET_OPEN.includes(text[off])) {
652
+ idx = off;
653
+ dir = 1;
654
+ }
655
+ else if (off > 0 && BRACKET_OPEN.includes(text[off - 1])) {
656
+ idx = off - 1;
657
+ dir = 1;
658
+ }
659
+ else if (off < text.length && BRACKET_CLOSE.includes(text[off])) {
660
+ idx = off;
661
+ dir = -1;
662
+ }
663
+ if (idx < 0 || inStr(idx))
664
+ return null;
665
+ const ch = text[idx];
666
+ const want = BRACKET_PAIR[ch];
667
+ let depth = 0;
668
+ if (dir > 0) {
669
+ for (let j = idx; j < text.length; j++) {
670
+ if (inStr(j))
671
+ continue;
672
+ const c = text[j];
673
+ if (c === ch)
674
+ depth++;
675
+ else if (c === want && --depth === 0)
676
+ return { a: idx, b: j };
677
+ }
678
+ }
679
+ else {
680
+ for (let j = idx; j >= 0; j--) {
681
+ if (inStr(j))
682
+ continue;
683
+ const c = text[j];
684
+ if (c === ch)
685
+ depth++;
686
+ else if (c === want && --depth === 0)
687
+ return { a: j, b: idx };
688
+ }
689
+ }
690
+ return null;
691
+ }
692
+ // ── 뷰 입력 훅 — Ace behaviours 관례의 타이핑 동작 ────────────
693
+ /** 자동 쌍 테이블 — 열기 문자 → 닫기 문자 */
694
+ const CODE_PAIRS = {
695
+ "(": ")",
696
+ "[": "]",
697
+ "{": "}",
698
+ '"': '"',
699
+ "'": "'",
700
+ "`": "`",
701
+ };
702
+ /** 타입오버 대상 — 닫기/인용이 바로 뒤에 있으면 입력 대신 넘어간다 */
703
+ const CODE_CLOSERS = ")]}'\"`";
704
+ const isCodeBlock = (state, block) => state.schema.nodeSpec(block.type)?.code === true;
705
+ export const codeBlockFeature = defineFeature({
706
+ name: "codeBlock",
707
+ description: "코드블록 — ``` 입력 규칙, 구문 하이라이트, Tab 들여쓰기·Mod-/ 주석·Alt-↑↓ 줄 이동·화살표 탈출 (Ace 관례)",
708
+ nodes: {
709
+ codeBlock: {
710
+ content: "inline*",
711
+ group: "block",
712
+ code: true,
713
+ codeHighlighter: highlightCode,
714
+ attrs: { language: { default: "" } },
715
+ toDOM: (n) => {
716
+ const lang = String(n.attrs?.["language"] ?? "");
717
+ return lang
718
+ ? { tag: "pre", attrs: { "data-language": lang } }
719
+ : { tag: "pre" };
720
+ },
721
+ parseDOM: [
722
+ {
723
+ tag: "pre",
724
+ getAttrs: (el) => ({
725
+ language: el.getAttribute("data-language") ??
726
+ /language-(\w+)/.exec(el.getAttribute("class") ?? "")?.[1] ??
727
+ "",
728
+ }),
729
+ },
730
+ ],
731
+ },
732
+ },
733
+ commands: {
734
+ toggleCodeBlock,
735
+ setCodeLanguage: (ctx, lang) => setCodeLanguage(String(lang ?? ""))(ctx),
736
+ toggleCodeComment,
737
+ moveCodeLines: (ctx, dir) => moveCodeLines(dir === "up" ? "up" : "down")(ctx),
738
+ duplicateCodeLines,
739
+ },
740
+ keymap: {
741
+ // Tab — 코드블록 들여쓰기. 밖이면 false → 리스트/표로 폴스루
742
+ Tab: indentCode,
743
+ "Shift-Tab": dedentCode,
744
+ // 코드블록 가장자리 줄의 화살표 탈출 (Tiptap exitOnArrow* 관례)
745
+ ArrowDown: exitCodeArrow("down"),
746
+ ArrowUp: exitCodeArrow("up"),
747
+ // 코드블록 줄 편집 — Ace/VS Code 관례 (밖이면 폴스루)
748
+ "Mod-/": toggleCodeComment,
749
+ "Alt-ArrowUp": moveCodeLines("up"),
750
+ "Alt-ArrowDown": moveCodeLines("down"),
751
+ "Mod-Shift-d": duplicateCodeLines,
752
+ },
753
+ inputRules: [
754
+ // 코드블록 — ``` / ~~~ + 스페이스. ```js 처럼 언어 지정 가능
755
+ // (Tiptap backtickInputRegex 관례 — 공백 트리거라 언어
756
+ // 접미사를 칠 시간이 있다)
757
+ blockRule(/^(?:```|~~~)([\w+-]*) $/, (m) => ({
758
+ type: "codeBlock",
759
+ attrs: { language: m[1] ?? "" },
760
+ })),
761
+ ],
762
+ input: {
763
+ /**
764
+ * 코드블록은 항상 순수 텍스트 붙여넣기 — HTML이 파싱돼 마크업
765
+ * 노드로 들어가면 코드가 깨진다 (Ace/CodeMirror 관례). 붙일
766
+ * 텍스트가 없으면 아무것도 넣지 않고 기본 경로만 막는다.
767
+ */
768
+ insertFromPaste(ctx, data) {
769
+ const blk = nodeAt(ctx.state.doc, ctx.anchor.path.slice(0, -1));
770
+ if (!blk || !isCodeBlock(ctx.state, blk))
771
+ return false;
772
+ if (data.text) {
773
+ if (!pointEq(ctx.anchor, ctx.focus)) {
774
+ ctx.tr.delete({ anchor: ctx.anchor, focus: ctx.focus });
775
+ }
776
+ ctx.tr
777
+ .insertText(data.text, ctx.anchor)
778
+ .setMeta("paste", true);
779
+ ctx.dispatch();
780
+ }
781
+ return true;
782
+ },
783
+ /**
784
+ * 자동 쌍/타입오버 (Ace behaviours 관례) — 열기는 쌍을 만들고
785
+ * 선택이 있으면 내용을 감싼다. 닫기/인용이 바로 뒤에 있으면
786
+ * 입력 대신 넘어간다(타입오버). 인용은 단어 안쪽/앞에선
787
+ * 아포스트로피로 보고 쌍을 만들지 않는다. IME 커밋
788
+ * (insertCompositionText)은 리터럴이므로 쌍을 만들지 않는다.
789
+ */
790
+ insertText(ctx, data, inputType) {
791
+ if (inputType !== "insertText" || data.length !== 1)
792
+ return false;
793
+ if (!isCodeBlock(ctx.state, ctx.block))
794
+ return false;
795
+ const { tr, block, text } = ctx;
796
+ const from = ctx.offsetOf(ctx.anchor);
797
+ const to = ctx.offsetOf(ctx.focus);
798
+ const next = text[from] ?? "";
799
+ if (from === to && CODE_CLOSERS.includes(data) && next === data) {
800
+ tr.setSelection(textSelection(ctx.charPoint(from + 1)));
801
+ ctx.dispatch();
802
+ return true;
803
+ }
804
+ const pair = CODE_PAIRS[data];
805
+ const quote = data === '"' || data === "'" || data === "`";
806
+ const wordL = from > 0 && /\w/.test(text[from - 1]);
807
+ const wordR = from < text.length && /\w/.test(text[from]);
808
+ // 문자열/주석 토큰 안에선 쌍을 만들지 않는다 — Ace가
809
+ // isCodeLike로 페어링을 끄는 관례 (s='{ 가 s='{''}'로
810
+ // 깨지지 않게). 타입오버는 문자열 안에서도 유효하다
811
+ if (pair &&
812
+ !(quote && (wordL || wordR)) &&
813
+ !(codeStringMask(ctx.state, block)?.(from) ?? false)) {
814
+ const inner = text.slice(from, to);
815
+ if (from !== to) {
816
+ tr.delete({ anchor: ctx.anchor, focus: ctx.focus });
817
+ }
818
+ tr.insertText(data + inner + pair, ctx.anchor).setMeta("history.group", "type");
819
+ // 셀렉션을 쌍 안쪽으로 — insertText는 끝에 놓으므로 역산
820
+ const end = tr.selection?.type === "text" ? tr.selection.focus : null;
821
+ if (end) {
822
+ const a2 = {
823
+ path: end.path,
824
+ offset: end.offset - pair.length - inner.length,
825
+ };
826
+ tr.setSelection(from === to
827
+ ? textSelection(a2)
828
+ : textSelection(a2, {
829
+ path: end.path,
830
+ offset: end.offset - pair.length,
831
+ }));
832
+ }
833
+ ctx.dispatch();
834
+ return true;
835
+ }
836
+ return false;
837
+ },
838
+ /**
839
+ * 개행 — 개행 + 자동 들여쓰기 (Ace/VS Code 관례). 빈 블록이거나
840
+ * 캐럿 끝의 줄이 공백뿐이면 그 꼬리를 지우고 블록을 탈출한다.
841
+ * 쌍 사이의 Enter는 가운데 들여쓴 줄을 만든다 — {|} → {\n↹|\n}
842
+ * (VS Code/Ace bracket expansion 관례).
843
+ */
844
+ insertParagraph(ctx) {
845
+ if (!isCodeBlock(ctx.state, ctx.block))
846
+ return false;
847
+ const { tr, blockPath, text } = ctx;
848
+ const caretOff = ctx.offsetOf(ctx.anchor);
849
+ const lineStart = caretOff === 0 ? 0 : text.lastIndexOf("\n", caretOff - 1) + 1;
850
+ const curLine = text.slice(lineStart, caretOff);
851
+ const atEmptyTail = caretOff === text.length &&
852
+ lineStart > 0 &&
853
+ /^[ \t]*$/.test(curLine);
854
+ if (!text || atEmptyTail) {
855
+ // 꼬리 빈 줄(개행 + 공백)을 지우고 블록 뒤에 본문 삽입
856
+ if (atEmptyTail) {
857
+ tr.delete({
858
+ anchor: ctx.charPoint(lineStart - 1),
859
+ focus: ctx.charPoint(text.length),
860
+ });
861
+ }
862
+ const parentPath = blockPath.slice(0, -1);
863
+ const at = {
864
+ path: parentPath,
865
+ offset: blockPath[blockPath.length - 1] + 1,
866
+ };
867
+ tr.insertNode(at, { type: "paragraph", content: [] });
868
+ tr.setSelection(textSelection({
869
+ path: [...at.path, at.offset],
870
+ offset: 0,
871
+ }));
872
+ }
873
+ else {
874
+ // 현재 줄의 선행 공백을 다음 줄에 복사 (Ace 자동 들여쓰기).
875
+ // 공백뿐인 줄에서는 들여쓰기를 이어가지 않는다
876
+ const indent = /^[ \t]+$/.test(curLine)
877
+ ? ""
878
+ : (curLine.match(/^[ \t]+/)?.[0] ?? "");
879
+ const pc = caretOff > 0 ? text[caretOff - 1] : "";
880
+ const nc = text[caretOff] ?? "";
881
+ if ("([{".includes(pc) && CODE_PAIRS[pc] === nc) {
882
+ tr.insertText("\n" + indent + "\t\n" + indent, ctx.anchor);
883
+ const fp = tr.selection?.type === "text" ? tr.selection.focus : null;
884
+ if (fp) {
885
+ tr.setSelection(textSelection({
886
+ path: fp.path,
887
+ offset: fp.offset - indent.length - 1,
888
+ }));
889
+ }
890
+ }
891
+ else {
892
+ tr.insertText("\n" + indent, ctx.anchor);
893
+ }
894
+ }
895
+ ctx.dispatch();
896
+ return true;
897
+ },
898
+ /**
899
+ * 빈 쌍 사이의 Backspace는 쌍을 통째로 지운다
900
+ * (Ace behaviours 관례 — `(|)`에서 BS → 빈 자리).
901
+ */
902
+ deleteBackward(ctx) {
903
+ if (!isCodeBlock(ctx.state, ctx.block))
904
+ return false;
905
+ const { tr, text } = ctx;
906
+ const off = ctx.offsetOf(ctx.anchor);
907
+ if (off > 0 &&
908
+ off < text.length &&
909
+ CODE_PAIRS[text[off - 1]] === text[off]) {
910
+ tr.delete({
911
+ anchor: ctx.charPoint(off - 1),
912
+ focus: ctx.charPoint(off + 1),
913
+ });
914
+ tr.setMeta("history.group", "delete");
915
+ ctx.dispatch();
916
+ return true;
917
+ }
918
+ return false;
919
+ },
920
+ /**
921
+ * DOM 오버레이 — 활성 줄 표시(Ace highlightActiveLine)와 캐럿
922
+ * 인접 괄호 쌍 하이라이트(Ace bracket matching). 둘 다 doc을
923
+ * 만지지 않는 DOM 마킹이다.
924
+ */
925
+ paintOverlay(el, state) {
926
+ // 활성 줄 — DOM 셀렉션 앵커가 있는 .me-cl에만 is-active를 단다
927
+ const s = el.ownerDocument.getSelection?.();
928
+ const node = s?.anchorNode ?? null;
929
+ if (node && el.contains(node)) {
930
+ const host = node.nodeType === 1 ? node : node.parentElement;
931
+ const line = host?.closest(".me-cl") ?? null;
932
+ el.querySelectorAll(".me-cl.is-active").forEach((n) => {
933
+ if (n !== line)
934
+ n.classList.remove("is-active");
935
+ });
936
+ line?.classList.add("is-active");
937
+ }
938
+ // 괄호 하이라이트 — CSS Custom Highlight API로 Range를 등록.
939
+ // DOM을 전혀 만지지 않아(텍스트 노드 분할 없음) 매핑·리렌더와
940
+ // 무관하게 안전. 미지원 환경(jsdom/구형)은 조용히 스킵
941
+ const win = el.ownerDocument.defaultView;
942
+ const reg = win?.CSS?.highlights;
943
+ const H = win?.Highlight;
944
+ if (!reg || !H)
945
+ return;
946
+ const pair = codeBracketAt(state);
947
+ if (!pair) {
948
+ reg.delete("me-bracket");
949
+ return;
950
+ }
951
+ // 문자 오프셋 → holder 텍스트 노드 안쪽 Range
952
+ const sel = state.selection;
953
+ const blockPath = sel.type === "text" ? sel.anchor.path.slice(0, -1) : [];
954
+ const block = nodeAt(state.doc, blockPath);
955
+ if (!block) {
956
+ reg.delete("me-bracket");
957
+ return;
958
+ }
959
+ const ranges = [];
960
+ for (const off of [pair.a, pair.b]) {
961
+ const p = blockTextPoint(block, blockPath, off);
962
+ const holder = el.querySelector(`[${PATH_ATTR}="${p.path.join(",")}"]`);
963
+ if (!holder)
964
+ return;
965
+ const w = el.ownerDocument.createTreeWalker(holder, 4);
966
+ let acc = 0;
967
+ let hit = null;
968
+ for (let n = w.nextNode(); n; n = w.nextNode()) {
969
+ const len = n.textContent?.length ?? 0;
970
+ if (p.offset >= acc && p.offset < acc + len) {
971
+ hit = el.ownerDocument.createRange();
972
+ hit.setStart(n, p.offset - acc);
973
+ hit.setEnd(n, p.offset - acc + 1);
974
+ break;
975
+ }
976
+ acc += len;
977
+ }
978
+ if (!hit)
979
+ return;
980
+ ranges.push(hit);
981
+ }
982
+ reg.set("me-bracket", new H(...ranges));
983
+ },
984
+ },
985
+ });
986
+ //# sourceMappingURL=code-block.js.map