@moda-editor/core 0.1.2 → 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.
Files changed (247) hide show
  1. package/dist/collab.d.ts +61 -0
  2. package/dist/collab.d.ts.map +1 -0
  3. package/dist/collab.js +67 -0
  4. package/dist/collab.js.map +1 -0
  5. package/dist/commands.d.ts +11 -0
  6. package/dist/commands.d.ts.map +1 -1
  7. package/dist/commands.js +48 -4
  8. package/dist/commands.js.map +1 -1
  9. package/dist/conformance/editor-base.d.ts +7 -0
  10. package/dist/conformance/editor-base.d.ts.map +1 -0
  11. package/dist/conformance/editor-base.js +280 -0
  12. package/dist/conformance/editor-base.js.map +1 -0
  13. package/dist/conformance/editor-codeblock.d.ts +8 -0
  14. package/dist/conformance/editor-codeblock.d.ts.map +1 -0
  15. package/dist/conformance/editor-codeblock.js +402 -0
  16. package/dist/conformance/editor-codeblock.js.map +1 -0
  17. package/dist/conformance/editor-ime.d.ts +7 -0
  18. package/dist/conformance/editor-ime.d.ts.map +1 -0
  19. package/dist/conformance/editor-ime.js +166 -0
  20. package/dist/conformance/editor-ime.js.map +1 -0
  21. package/dist/conformance/editor-input.d.ts +8 -0
  22. package/dist/conformance/editor-input.d.ts.map +1 -0
  23. package/dist/conformance/editor-input.js +676 -0
  24. package/dist/conformance/editor-input.js.map +1 -0
  25. package/dist/conformance/editor-nodeview.d.ts +7 -0
  26. package/dist/conformance/editor-nodeview.d.ts.map +1 -0
  27. package/dist/conformance/editor-nodeview.js +120 -0
  28. package/dist/conformance/editor-nodeview.js.map +1 -0
  29. package/dist/conformance/editor-overlay.d.ts +8 -0
  30. package/dist/conformance/editor-overlay.d.ts.map +1 -0
  31. package/dist/conformance/editor-overlay.js +323 -0
  32. package/dist/conformance/editor-overlay.js.map +1 -0
  33. package/dist/conformance/fixtures.d.ts +4 -0
  34. package/dist/conformance/fixtures.d.ts.map +1 -0
  35. package/dist/conformance/fixtures.js +15 -0
  36. package/dist/conformance/fixtures.js.map +1 -0
  37. package/dist/conformance/index.d.ts +14 -0
  38. package/dist/conformance/index.d.ts.map +1 -0
  39. package/dist/conformance/index.js +28 -0
  40. package/dist/conformance/index.js.map +1 -0
  41. package/dist/conformance/types.d.ts +51 -0
  42. package/dist/conformance/types.d.ts.map +1 -0
  43. package/dist/conformance/types.js +2 -0
  44. package/dist/conformance/types.js.map +1 -0
  45. package/dist/conformance/widget.d.ts +3 -0
  46. package/dist/conformance/widget.d.ts.map +1 -0
  47. package/dist/conformance/widget.js +49 -0
  48. package/dist/conformance/widget.js.map +1 -0
  49. package/dist/conformance.d.ts +1 -58
  50. package/dist/conformance.d.ts.map +1 -1
  51. package/dist/conformance.js +3 -1608
  52. package/dist/conformance.js.map +1 -1
  53. package/dist/editor/extension.d.ts +51 -5
  54. package/dist/editor/extension.d.ts.map +1 -1
  55. package/dist/features/ace.d.ts +4 -0
  56. package/dist/features/ace.d.ts.map +1 -1
  57. package/dist/features/ace.js +57 -25
  58. package/dist/features/ace.js.map +1 -1
  59. package/dist/features/block-dnd.d.ts +7 -0
  60. package/dist/features/block-dnd.d.ts.map +1 -0
  61. package/dist/features/block-dnd.js +207 -0
  62. package/dist/features/block-dnd.js.map +1 -0
  63. package/dist/features/code-block.d.ts +12 -58
  64. package/dist/features/code-block.d.ts.map +1 -1
  65. package/dist/features/code-block.js +155 -630
  66. package/dist/features/code-block.js.map +1 -1
  67. package/dist/features/code-context.d.ts +81 -0
  68. package/dist/features/code-context.d.ts.map +1 -0
  69. package/dist/features/code-context.js +322 -0
  70. package/dist/features/code-context.js.map +1 -0
  71. package/dist/features/code-edit.d.ts +50 -0
  72. package/dist/features/code-edit.d.ts.map +1 -0
  73. package/dist/features/code-edit.js +412 -0
  74. package/dist/features/code-edit.js.map +1 -0
  75. package/dist/features/code-format.d.ts +21 -0
  76. package/dist/features/code-format.d.ts.map +1 -0
  77. package/dist/features/code-format.js +410 -0
  78. package/dist/features/code-format.js.map +1 -0
  79. package/dist/features/doc.d.ts.map +1 -1
  80. package/dist/features/doc.js +1 -1
  81. package/dist/features/doc.js.map +1 -1
  82. package/dist/features/heading.d.ts.map +1 -1
  83. package/dist/features/heading.js.map +1 -1
  84. package/dist/features/image.d.ts +14 -1
  85. package/dist/features/image.d.ts.map +1 -1
  86. package/dist/features/image.js +288 -11
  87. package/dist/features/image.js.map +1 -1
  88. package/dist/features/index.d.ts +7 -0
  89. package/dist/features/index.d.ts.map +1 -1
  90. package/dist/features/index.js +16 -0
  91. package/dist/features/index.js.map +1 -1
  92. package/dist/features/inline-style.d.ts.map +1 -1
  93. package/dist/features/inline-style.js +4 -1
  94. package/dist/features/inline-style.js.map +1 -1
  95. package/dist/features/link.d.ts.map +1 -1
  96. package/dist/features/link.js +1 -1
  97. package/dist/features/link.js.map +1 -1
  98. package/dist/features/markdown.d.ts +15 -0
  99. package/dist/features/markdown.d.ts.map +1 -0
  100. package/dist/features/markdown.js +80 -0
  101. package/dist/features/markdown.js.map +1 -0
  102. package/dist/features/marks.d.ts.map +1 -1
  103. package/dist/features/marks.js +76 -4
  104. package/dist/features/marks.js.map +1 -1
  105. package/dist/features/mention.d.ts +30 -0
  106. package/dist/features/mention.d.ts.map +1 -0
  107. package/dist/features/mention.js +89 -0
  108. package/dist/features/mention.js.map +1 -0
  109. package/dist/features/script.d.ts.map +1 -1
  110. package/dist/features/script.js.map +1 -1
  111. package/dist/features/search.d.ts +73 -0
  112. package/dist/features/search.d.ts.map +1 -0
  113. package/dist/features/search.js +252 -0
  114. package/dist/features/search.js.map +1 -0
  115. package/dist/features/shared.d.ts +69 -0
  116. package/dist/features/shared.d.ts.map +1 -0
  117. package/dist/features/shared.js +272 -0
  118. package/dist/features/shared.js.map +1 -0
  119. package/dist/features/table.d.ts +16 -3
  120. package/dist/features/table.d.ts.map +1 -1
  121. package/dist/features/table.js +309 -8
  122. package/dist/features/table.js.map +1 -1
  123. package/dist/features/testkit.d.ts.map +1 -1
  124. package/dist/features/testkit.js +1 -1
  125. package/dist/features/testkit.js.map +1 -1
  126. package/dist/history.d.ts.map +1 -1
  127. package/dist/history.js +65 -20
  128. package/dist/history.js.map +1 -1
  129. package/dist/index.d.ts +20 -7
  130. package/dist/index.d.ts.map +1 -1
  131. package/dist/index.js +17 -6
  132. package/dist/index.js.map +1 -1
  133. package/dist/model/schema.d.ts.map +1 -1
  134. package/dist/model/schema.js +2 -1
  135. package/dist/model/schema.js.map +1 -1
  136. package/dist/parse/html.d.ts.map +1 -1
  137. package/dist/parse/html.js +38 -6
  138. package/dist/parse/html.js.map +1 -1
  139. package/dist/parse/sanitize.d.ts.map +1 -1
  140. package/dist/parse/sanitize.js +14 -2
  141. package/dist/parse/sanitize.js.map +1 -1
  142. package/dist/presets/highlight.d.ts +17 -0
  143. package/dist/presets/highlight.d.ts.map +1 -1
  144. package/dist/presets/highlight.js +52 -4
  145. package/dist/presets/highlight.js.map +1 -1
  146. package/dist/presets/markdown-parse.d.ts +13 -0
  147. package/dist/presets/markdown-parse.d.ts.map +1 -0
  148. package/dist/presets/markdown-parse.js +398 -0
  149. package/dist/presets/markdown-parse.js.map +1 -0
  150. package/dist/presets/markdown.d.ts +45 -0
  151. package/dist/presets/markdown.d.ts.map +1 -0
  152. package/dist/presets/markdown.js +195 -0
  153. package/dist/presets/markdown.js.map +1 -0
  154. package/dist/state/apply.d.ts.map +1 -1
  155. package/dist/state/apply.js +19 -11
  156. package/dist/state/apply.js.map +1 -1
  157. package/dist/state/delete-ops.d.ts +34 -0
  158. package/dist/state/delete-ops.d.ts.map +1 -0
  159. package/dist/state/delete-ops.js +204 -0
  160. package/dist/state/delete-ops.js.map +1 -0
  161. package/dist/state/input-rules.d.ts +30 -0
  162. package/dist/state/input-rules.d.ts.map +1 -0
  163. package/dist/state/input-rules.js +70 -0
  164. package/dist/state/input-rules.js.map +1 -0
  165. package/dist/state/insert-ops.d.ts +54 -0
  166. package/dist/state/insert-ops.d.ts.map +1 -0
  167. package/dist/state/insert-ops.js +186 -0
  168. package/dist/state/insert-ops.js.map +1 -0
  169. package/dist/state/list-ops.d.ts +46 -0
  170. package/dist/state/list-ops.d.ts.map +1 -0
  171. package/dist/state/list-ops.js +231 -0
  172. package/dist/state/list-ops.js.map +1 -0
  173. package/dist/state/navigate.d.ts.map +1 -1
  174. package/dist/state/navigate.js +3 -1
  175. package/dist/state/navigate.js.map +1 -1
  176. package/dist/state/resolve-insert.d.ts +55 -0
  177. package/dist/state/resolve-insert.d.ts.map +1 -0
  178. package/dist/state/resolve-insert.js +118 -0
  179. package/dist/state/resolve-insert.js.map +1 -0
  180. package/dist/state/slice-ops.d.ts +46 -0
  181. package/dist/state/slice-ops.d.ts.map +1 -0
  182. package/dist/state/slice-ops.js +150 -0
  183. package/dist/state/slice-ops.js.map +1 -0
  184. package/dist/state/state.d.ts +6 -1
  185. package/dist/state/state.d.ts.map +1 -1
  186. package/dist/state/state.js +10 -0
  187. package/dist/state/state.js.map +1 -1
  188. package/dist/state/transaction.d.ts +40 -53
  189. package/dist/state/transaction.d.ts.map +1 -1
  190. package/dist/state/transaction.js +62 -829
  191. package/dist/state/transaction.js.map +1 -1
  192. package/dist/styles.css +472 -46
  193. package/dist/view/adapter.d.ts +84 -0
  194. package/dist/view/adapter.d.ts.map +1 -0
  195. package/dist/view/adapter.js +76 -0
  196. package/dist/view/adapter.js.map +1 -0
  197. package/dist/view/caret.d.ts +25 -0
  198. package/dist/view/caret.d.ts.map +1 -0
  199. package/dist/view/caret.js +43 -0
  200. package/dist/view/caret.js.map +1 -0
  201. package/dist/view/decoration.d.ts +9 -0
  202. package/dist/view/decoration.d.ts.map +1 -0
  203. package/dist/view/decoration.js +214 -0
  204. package/dist/view/decoration.js.map +1 -0
  205. package/dist/view/dirty-keys.d.ts +18 -0
  206. package/dist/view/dirty-keys.d.ts.map +1 -0
  207. package/dist/view/dirty-keys.js +31 -0
  208. package/dist/view/dirty-keys.js.map +1 -0
  209. package/dist/view/dom.d.ts.map +1 -1
  210. package/dist/view/dom.js +69 -27
  211. package/dist/view/dom.js.map +1 -1
  212. package/dist/view/hook-ctx.d.ts +32 -0
  213. package/dist/view/hook-ctx.d.ts.map +1 -0
  214. package/dist/view/hook-ctx.js +64 -0
  215. package/dist/view/hook-ctx.js.map +1 -0
  216. package/dist/view/ime.d.ts +72 -0
  217. package/dist/view/ime.d.ts.map +1 -0
  218. package/dist/view/ime.js +185 -0
  219. package/dist/view/ime.js.map +1 -0
  220. package/dist/view/input.d.ts +12 -12
  221. package/dist/view/input.d.ts.map +1 -1
  222. package/dist/view/input.js +122 -435
  223. package/dist/view/input.js.map +1 -1
  224. package/dist/view/keys.d.ts +25 -0
  225. package/dist/view/keys.d.ts.map +1 -0
  226. package/dist/view/keys.js +106 -0
  227. package/dist/view/keys.js.map +1 -0
  228. package/dist/view/line-nav.d.ts +13 -0
  229. package/dist/view/line-nav.d.ts.map +1 -0
  230. package/dist/view/line-nav.js +114 -0
  231. package/dist/view/line-nav.js.map +1 -0
  232. package/dist/view/mount.d.ts.map +1 -1
  233. package/dist/view/mount.js +48 -32
  234. package/dist/view/mount.js.map +1 -1
  235. package/dist/view/node-selection.d.ts +33 -0
  236. package/dist/view/node-selection.d.ts.map +1 -0
  237. package/dist/view/node-selection.js +107 -0
  238. package/dist/view/node-selection.js.map +1 -0
  239. package/dist/view/render.d.ts +11 -2
  240. package/dist/view/render.d.ts.map +1 -1
  241. package/dist/view/render.js +17 -3
  242. package/dist/view/render.js.map +1 -1
  243. package/dist/view/serialize.d.ts +8 -0
  244. package/dist/view/serialize.d.ts.map +1 -1
  245. package/dist/view/serialize.js +33 -5
  246. package/dist/view/serialize.js.map +1 -1
  247. package/package.json +1 -1
@@ -1,10 +1,20 @@
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";
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({ path: [a, 0], offset: merged.content?.[0]?.text?.length ?? 0 }));
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
  }
@@ -68,627 +79,29 @@ export const toggleCodeBlock = ({ state, tr, dispatch }) => {
68
79
  export function setCodeLanguage(language) {
69
80
  return ({ state, tr, dispatch }) => {
70
81
  const blockPath = caretBlockPath(state);
71
- const block = blockPath ? nodeAt(state.doc, blockPath) : undefined;
72
- if (!blockPath || block?.type !== "codeBlock")
82
+ if (!blockPath)
73
83
  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,
84
+ return setCodeLanguageAt(blockPath, language)({
85
+ state,
86
+ tr,
87
+ dispatch,
119
88
  });
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
89
  };
348
90
  }
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
91
  /**
374
- * 문자 오프셋 편집 목록(삽입/삭제)을 블록에 적용하고 새 doc 기준
375
- * 텍스트 자식별 길이 배열을 돌려준다. ops는 겹치지 않는 원본 좌표 —
376
- * 뒤쪽(큰 pos)부터 적용해야 앞쪽 좌표가 안 밀린다.
92
+ * 경로로 지정한 코드블록의 언어 — 노드 뷰 크롬 바처럼 캐럿과
93
+ * 무관하게 특정 블록을 바꿀 때 쓴다.
377
94
  */
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) {
95
+ export function setCodeLanguageAt(blockPath, language) {
548
96
  return ({ state, tr, dispatch }) => {
549
- const ctx = codeContext(state);
550
- if (!ctx)
97
+ const block = nodeAt(state.doc, blockPath);
98
+ if (!block || !state.schema.nodeSpec(block.type)?.code)
551
99
  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)));
100
+ tr.setNodeAttr(blockPath, "language", language);
600
101
  dispatch();
601
102
  return true;
602
103
  };
603
104
  }
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
105
  // ── 뷰 입력 훅 — Ace behaviours 관례의 타이핑 동작 ────────────
693
106
  /** 자동 쌍 테이블 — 열기 문자 → 닫기 문자 */
694
107
  const CODE_PAIRS = {
@@ -701,10 +114,108 @@ const CODE_PAIRS = {
701
114
  };
702
115
  /** 타입오버 대상 — 닫기/인용이 바로 뒤에 있으면 입력 대신 넘어간다 */
703
116
  const CODE_CLOSERS = ")]}'\"`";
704
- const isCodeBlock = (state, block) => state.schema.nodeSpec(block.type)?.code === true;
117
+ const codeUIs = new WeakMap();
118
+ /** 캐럿이 속한 코드블록의 pre — 코드 캐럿 아니면 null */
119
+ function activePreEl(el, state) {
120
+ const ctx = codeContext(state);
121
+ if (!ctx)
122
+ return null;
123
+ const pre = el.querySelector(`pre[${PATH_ATTR}="${ctx.blockPath.join(",")}"]`);
124
+ // nodeView 안의 코드블록은 컴포넌트 크롬(.me-codehead)이 소유 —
125
+ // 오버레이와 이중으로 뜨지 않게 둔다
126
+ if (!pre || pre.closest(".me-node-view"))
127
+ return null;
128
+ return { pre, lang: String(ctx.block.attrs?.["language"] ?? "") };
129
+ }
130
+ function ensureCodeUI(el, editor) {
131
+ const hit = codeUIs.get(el);
132
+ if (hit)
133
+ return hit;
134
+ const doc = el.ownerDocument;
135
+ const box = doc.createElement("div");
136
+ box.className = "me-codeui";
137
+ box.hidden = true;
138
+ const sel = doc.createElement("select");
139
+ sel.className = "me-lang me-codelang";
140
+ sel.title = "코드 언어";
141
+ const mkBtn = (label, act, title) => {
142
+ const b = doc.createElement("button");
143
+ b.type = "button";
144
+ b.className = "me-codebtn";
145
+ b.setAttribute("data-code-act", act);
146
+ b.title = title;
147
+ b.textContent = label;
148
+ return b;
149
+ };
150
+ box.append(sel, mkBtn("정리", "format", "코드 서식 정리 (Alt+Shift+F) — JSON pretty-print·줄 끝 공백·빈 줄 정리"), mkBtn("⧉", "copy", "코드 복사"));
151
+ (el.parentElement ?? el).appendChild(box);
152
+ const ui = { box, sel, observer: null };
153
+ codeUIs.set(el, ui);
154
+ // 버튼은 캐럿을 빼앗지 않는다 — select는 기본 동작(드롭다운)이
155
+ // 필요해 mousedown을 먹지 않는다
156
+ box.addEventListener("mousedown", (e) => {
157
+ if (!(e.target instanceof HTMLSelectElement))
158
+ e.preventDefault();
159
+ });
160
+ sel.addEventListener("change", () => {
161
+ editor.commands["setCodeLanguage"]?.(sel.value);
162
+ });
163
+ box.addEventListener("click", (e) => {
164
+ const t = e.target;
165
+ if (!(t instanceof Element))
166
+ return;
167
+ const act = t.closest("[data-code-act]")?.getAttribute("data-code-act");
168
+ if (act === "format") {
169
+ editor.commands["formatCode"]?.();
170
+ }
171
+ else if (act === "copy") {
172
+ const text = codeTextAt(editor.state);
173
+ if (text != null) {
174
+ void el.ownerDocument.defaultView?.navigator?.clipboard?.writeText(text);
175
+ }
176
+ }
177
+ });
178
+ // 레이아웃 변화로 위치가 어긋나면 다시 맞춘다
179
+ if (typeof ResizeObserver !== "undefined") {
180
+ ui.observer = new ResizeObserver(() => {
181
+ if (!ui.box.hidden)
182
+ paintCodeOverlay(el, editor);
183
+ });
184
+ ui.observer.observe(el);
185
+ }
186
+ return ui;
187
+ }
188
+ /** 캐럿이 코드블록 안이면 바를 pre 우측 상단에 맞춘다 — 아니면 숨긴다 */
189
+ function paintCodeOverlay(el, editor) {
190
+ const ui = ensureCodeUI(el, editor);
191
+ const found = activePreEl(el, editor.state);
192
+ if (!found) {
193
+ ui.box.hidden = true;
194
+ return;
195
+ }
196
+ const { pre, lang } = found;
197
+ const host = ui.box.offsetParent ?? el.parentElement;
198
+ const hr = host?.getBoundingClientRect();
199
+ const r = pre.getBoundingClientRect();
200
+ ui.box.style.left = `${r.right - (hr?.left ?? 0)}px`;
201
+ ui.box.style.top = `${r.top - (hr?.top ?? 0)}px`;
202
+ // 셀렉트 옵션 동기화 — 목록에 없는 언어는 원값 옵션을 덧붙인다
203
+ const opts = codeLangOptions(lang);
204
+ const cur = [...ui.sel.options].map((o) => o.value);
205
+ if (cur.length !== opts.length || cur.some((v, i) => v !== opts[i].value)) {
206
+ ui.sel.replaceChildren(...opts.map((l) => {
207
+ const o = el.ownerDocument.createElement("option");
208
+ o.value = l.value;
209
+ o.textContent = l.label;
210
+ return o;
211
+ }));
212
+ }
213
+ ui.sel.value = lang;
214
+ ui.box.hidden = false;
215
+ }
705
216
  export const codeBlockFeature = defineFeature({
706
217
  name: "codeBlock",
707
- description: "코드블록 — ``` 입력 규칙, 구문 하이라이트, Tab 들여쓰기·Mod-/ 주석·Alt-↑↓ 줄 이동·화살표 탈출 (Ace 관례)",
218
+ description: "코드블록 — ``` 입력 규칙, 구문 하이라이트, 언어 셀렉트·서식 정리·복사 크롬(nodeViews 코드 카드 헤더, 미등록 시 .me-codeui 폴백), Tab 들여쓰기·Mod-/ 주석·Alt-↑↓ 줄 이동·화살표 탈출 (Ace 관례)",
708
219
  nodes: {
709
220
  codeBlock: {
710
221
  content: "inline*",
@@ -733,6 +244,9 @@ export const codeBlockFeature = defineFeature({
733
244
  commands: {
734
245
  toggleCodeBlock,
735
246
  setCodeLanguage: (ctx, lang) => setCodeLanguage(String(lang ?? ""))(ctx),
247
+ setCodeLanguageAt: (ctx, path, lang) => setCodeLanguageAt(path, String(lang ?? ""))(ctx),
248
+ formatCode,
249
+ formatCodeAt: (ctx, path) => formatCodeAt(path)(ctx),
736
250
  toggleCodeComment,
737
251
  moveCodeLines: (ctx, dir) => moveCodeLines(dir === "up" ? "up" : "down")(ctx),
738
252
  duplicateCodeLines,
@@ -746,6 +260,8 @@ export const codeBlockFeature = defineFeature({
746
260
  ArrowUp: exitCodeArrow("up"),
747
261
  // 코드블록 줄 편집 — Ace/VS Code 관례 (밖이면 폴스루)
748
262
  "Mod-/": toggleCodeComment,
263
+ // 서식 정리 — VS Code Format Document (Shift+Alt+F) 관례
264
+ "Alt-Shift-f": formatCode,
749
265
  "Alt-ArrowUp": moveCodeLines("up"),
750
266
  "Alt-ArrowDown": moveCodeLines("down"),
751
267
  "Mod-Shift-d": duplicateCodeLines,
@@ -754,9 +270,12 @@ export const codeBlockFeature = defineFeature({
754
270
  // 코드블록 — ``` / ~~~ + 스페이스. ```js 처럼 언어 지정 가능
755
271
  // (Tiptap backtickInputRegex 관례 — 공백 트리거라 언어
756
272
  // 접미사를 칠 시간이 있다)
757
- blockRule(/^(?:```|~~~)([\w+-]*) $/, (m) => ({
273
+ blockRule(/^(?:```|~~~)([\w+-]*) $/, (m, block) => ({
758
274
  type: "codeBlock",
759
275
  attrs: { language: m[1] ?? "" },
276
+ // 코드는 마크를 받지 않는다 — toggleCodeBlock과 같은 규칙으로
277
+ // 기존 인라인 마크(대기 서식이 입혀진 fence 텍스트 등)를 벗긴다
278
+ content: (block.content ?? []).map((c) => ({ ...c, marks: undefined })),
760
279
  })),
761
280
  ],
762
281
  input: {
@@ -773,9 +292,7 @@ export const codeBlockFeature = defineFeature({
773
292
  if (!pointEq(ctx.anchor, ctx.focus)) {
774
293
  ctx.tr.delete({ anchor: ctx.anchor, focus: ctx.focus });
775
294
  }
776
- ctx.tr
777
- .insertText(data.text, ctx.anchor)
778
- .setMeta("paste", true);
295
+ ctx.tr.insertText(data.text, ctx.anchor).setMeta("paste", true);
779
296
  ctx.dispatch();
780
297
  }
781
298
  return true;
@@ -848,9 +365,7 @@ export const codeBlockFeature = defineFeature({
848
365
  const caretOff = ctx.offsetOf(ctx.anchor);
849
366
  const lineStart = caretOff === 0 ? 0 : text.lastIndexOf("\n", caretOff - 1) + 1;
850
367
  const curLine = text.slice(lineStart, caretOff);
851
- const atEmptyTail = caretOff === text.length &&
852
- lineStart > 0 &&
853
- /^[ \t]*$/.test(curLine);
368
+ const atEmptyTail = caretOff === text.length && lineStart > 0 && /^[ \t]*$/.test(curLine);
854
369
  if (!text || atEmptyTail) {
855
370
  // 꼬리 빈 줄(개행 + 공백)을 지우고 블록 뒤에 본문 삽입
856
371
  if (atEmptyTail) {
@@ -918,11 +433,13 @@ export const codeBlockFeature = defineFeature({
918
433
  return false;
919
434
  },
920
435
  /**
921
- * DOM 오버레이 — 활성 줄 표시(Ace highlightActiveLine)와 캐럿
922
- * 인접 괄호 쌍 하이라이트(Ace bracket matching). 둘 다 doc을
923
- * 만지지 않는 DOM 마킹이다.
436
+ * DOM 오버레이 — 활성 줄 표시(Ace highlightActiveLine)·캐럿
437
+ * 인접 괄호 쌍 하이라이트(Ace bracket matching)·코드 크롬 바
438
+ * (.me-codeui — 언어 셀렉트/정리/복사). doc을 만지지 않는다.
924
439
  */
925
- paintOverlay(el, state) {
440
+ paintOverlay(el, state, editor) {
441
+ // 캐럿이 코드블록 안이면 크롬 바를 pre 우측 상단에 맞춘다
442
+ paintCodeOverlay(el, editor);
926
443
  // 활성 줄 — DOM 셀렉션 앵커가 있는 .me-cl에만 is-active를 단다
927
444
  const s = el.ownerDocument.getSelection?.();
928
445
  const node = s?.anchorNode ?? null;
@@ -981,6 +498,14 @@ export const codeBlockFeature = defineFeature({
981
498
  }
982
499
  reg.set("me-bracket", new H(...ranges));
983
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
+ },
984
509
  },
985
510
  });
986
511
  //# sourceMappingURL=code-block.js.map