@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.
Files changed (243) 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 -1748
  52. package/dist/conformance.js.map +1 -1
  53. package/dist/editor/extension.d.ts +47 -3
  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 +34 -13
  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 +9 -84
  64. package/dist/features/code-block.d.ts.map +1 -1
  65. package/dist/features/code-block.js +37 -1027
  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.map +1 -1
  85. package/dist/features/image.js +11 -2
  86. package/dist/features/image.js.map +1 -1
  87. package/dist/features/index.d.ts +7 -0
  88. package/dist/features/index.d.ts.map +1 -1
  89. package/dist/features/index.js +16 -0
  90. package/dist/features/index.js.map +1 -1
  91. package/dist/features/inline-style.d.ts.map +1 -1
  92. package/dist/features/inline-style.js +4 -1
  93. package/dist/features/inline-style.js.map +1 -1
  94. package/dist/features/link.d.ts.map +1 -1
  95. package/dist/features/link.js +1 -1
  96. package/dist/features/link.js.map +1 -1
  97. package/dist/features/markdown.d.ts +15 -0
  98. package/dist/features/markdown.d.ts.map +1 -0
  99. package/dist/features/markdown.js +80 -0
  100. package/dist/features/markdown.js.map +1 -0
  101. package/dist/features/marks.d.ts.map +1 -1
  102. package/dist/features/marks.js +49 -4
  103. package/dist/features/marks.js.map +1 -1
  104. package/dist/features/mention.d.ts +30 -0
  105. package/dist/features/mention.d.ts.map +1 -0
  106. package/dist/features/mention.js +89 -0
  107. package/dist/features/mention.js.map +1 -0
  108. package/dist/features/script.d.ts.map +1 -1
  109. package/dist/features/script.js.map +1 -1
  110. package/dist/features/search.d.ts +73 -0
  111. package/dist/features/search.d.ts.map +1 -0
  112. package/dist/features/search.js +252 -0
  113. package/dist/features/search.js.map +1 -0
  114. package/dist/features/shared.d.ts +69 -0
  115. package/dist/features/shared.d.ts.map +1 -0
  116. package/dist/features/shared.js +272 -0
  117. package/dist/features/shared.js.map +1 -0
  118. package/dist/features/table.d.ts.map +1 -1
  119. package/dist/features/table.js +17 -3
  120. package/dist/features/table.js.map +1 -1
  121. package/dist/features/testkit.d.ts.map +1 -1
  122. package/dist/features/testkit.js +1 -1
  123. package/dist/features/testkit.js.map +1 -1
  124. package/dist/history.d.ts.map +1 -1
  125. package/dist/history.js +65 -20
  126. package/dist/history.js.map +1 -1
  127. package/dist/index.d.ts +19 -7
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +16 -6
  130. package/dist/index.js.map +1 -1
  131. package/dist/model/schema.d.ts.map +1 -1
  132. package/dist/model/schema.js +2 -1
  133. package/dist/model/schema.js.map +1 -1
  134. package/dist/parse/html.d.ts.map +1 -1
  135. package/dist/parse/html.js +38 -6
  136. package/dist/parse/html.js.map +1 -1
  137. package/dist/parse/sanitize.d.ts.map +1 -1
  138. package/dist/parse/sanitize.js +4 -1
  139. package/dist/parse/sanitize.js.map +1 -1
  140. package/dist/presets/highlight.d.ts.map +1 -1
  141. package/dist/presets/highlight.js +1 -4
  142. package/dist/presets/highlight.js.map +1 -1
  143. package/dist/presets/markdown-parse.d.ts +13 -0
  144. package/dist/presets/markdown-parse.d.ts.map +1 -0
  145. package/dist/presets/markdown-parse.js +398 -0
  146. package/dist/presets/markdown-parse.js.map +1 -0
  147. package/dist/presets/markdown.d.ts +45 -0
  148. package/dist/presets/markdown.d.ts.map +1 -0
  149. package/dist/presets/markdown.js +195 -0
  150. package/dist/presets/markdown.js.map +1 -0
  151. package/dist/state/apply.d.ts.map +1 -1
  152. package/dist/state/apply.js +19 -11
  153. package/dist/state/apply.js.map +1 -1
  154. package/dist/state/delete-ops.d.ts +34 -0
  155. package/dist/state/delete-ops.d.ts.map +1 -0
  156. package/dist/state/delete-ops.js +204 -0
  157. package/dist/state/delete-ops.js.map +1 -0
  158. package/dist/state/input-rules.d.ts +30 -0
  159. package/dist/state/input-rules.d.ts.map +1 -0
  160. package/dist/state/input-rules.js +70 -0
  161. package/dist/state/input-rules.js.map +1 -0
  162. package/dist/state/insert-ops.d.ts +54 -0
  163. package/dist/state/insert-ops.d.ts.map +1 -0
  164. package/dist/state/insert-ops.js +186 -0
  165. package/dist/state/insert-ops.js.map +1 -0
  166. package/dist/state/list-ops.d.ts +46 -0
  167. package/dist/state/list-ops.d.ts.map +1 -0
  168. package/dist/state/list-ops.js +231 -0
  169. package/dist/state/list-ops.js.map +1 -0
  170. package/dist/state/navigate.d.ts.map +1 -1
  171. package/dist/state/navigate.js +3 -1
  172. package/dist/state/navigate.js.map +1 -1
  173. package/dist/state/resolve-insert.d.ts +55 -0
  174. package/dist/state/resolve-insert.d.ts.map +1 -0
  175. package/dist/state/resolve-insert.js +118 -0
  176. package/dist/state/resolve-insert.js.map +1 -0
  177. package/dist/state/slice-ops.d.ts +46 -0
  178. package/dist/state/slice-ops.d.ts.map +1 -0
  179. package/dist/state/slice-ops.js +150 -0
  180. package/dist/state/slice-ops.js.map +1 -0
  181. package/dist/state/state.d.ts +6 -1
  182. package/dist/state/state.d.ts.map +1 -1
  183. package/dist/state/state.js +10 -0
  184. package/dist/state/state.js.map +1 -1
  185. package/dist/state/transaction.d.ts +43 -49
  186. package/dist/state/transaction.d.ts.map +1 -1
  187. package/dist/state/transaction.js +62 -858
  188. package/dist/state/transaction.js.map +1 -1
  189. package/dist/styles.css +160 -29
  190. package/dist/view/adapter.d.ts +84 -0
  191. package/dist/view/adapter.d.ts.map +1 -0
  192. package/dist/view/adapter.js +76 -0
  193. package/dist/view/adapter.js.map +1 -0
  194. package/dist/view/caret.d.ts +25 -0
  195. package/dist/view/caret.d.ts.map +1 -0
  196. package/dist/view/caret.js +43 -0
  197. package/dist/view/caret.js.map +1 -0
  198. package/dist/view/decoration.d.ts +9 -0
  199. package/dist/view/decoration.d.ts.map +1 -0
  200. package/dist/view/decoration.js +214 -0
  201. package/dist/view/decoration.js.map +1 -0
  202. package/dist/view/dirty-keys.d.ts +18 -0
  203. package/dist/view/dirty-keys.d.ts.map +1 -0
  204. package/dist/view/dirty-keys.js +31 -0
  205. package/dist/view/dirty-keys.js.map +1 -0
  206. package/dist/view/dom.d.ts.map +1 -1
  207. package/dist/view/dom.js +7 -5
  208. package/dist/view/dom.js.map +1 -1
  209. package/dist/view/hook-ctx.d.ts +32 -0
  210. package/dist/view/hook-ctx.d.ts.map +1 -0
  211. package/dist/view/hook-ctx.js +64 -0
  212. package/dist/view/hook-ctx.js.map +1 -0
  213. package/dist/view/ime.d.ts +72 -0
  214. package/dist/view/ime.d.ts.map +1 -0
  215. package/dist/view/ime.js +185 -0
  216. package/dist/view/ime.js.map +1 -0
  217. package/dist/view/input.d.ts +12 -12
  218. package/dist/view/input.d.ts.map +1 -1
  219. package/dist/view/input.js +83 -534
  220. package/dist/view/input.js.map +1 -1
  221. package/dist/view/keys.d.ts +25 -0
  222. package/dist/view/keys.d.ts.map +1 -0
  223. package/dist/view/keys.js +106 -0
  224. package/dist/view/keys.js.map +1 -0
  225. package/dist/view/line-nav.d.ts +13 -0
  226. package/dist/view/line-nav.d.ts.map +1 -0
  227. package/dist/view/line-nav.js +114 -0
  228. package/dist/view/line-nav.js.map +1 -0
  229. package/dist/view/mount.d.ts.map +1 -1
  230. package/dist/view/mount.js +43 -39
  231. package/dist/view/mount.js.map +1 -1
  232. package/dist/view/node-selection.d.ts +33 -0
  233. package/dist/view/node-selection.d.ts.map +1 -0
  234. package/dist/view/node-selection.js +107 -0
  235. package/dist/view/node-selection.js.map +1 -0
  236. package/dist/view/render.d.ts +4 -1
  237. package/dist/view/render.d.ts.map +1 -1
  238. package/dist/view/render.js +13 -3
  239. package/dist/view/render.js.map +1 -1
  240. package/dist/view/serialize.d.ts.map +1 -1
  241. package/dist/view/serialize.js +14 -5
  242. package/dist/view/serialize.js.map +1 -1
  243. package/package.json +1 -1
@@ -1,45 +1,31 @@
1
- import { firstTextPath, lastTextPath, nodeAt } from "../model/traverse.js";
2
- import { applyStep, computeSplitOps, mapThroughSplitOp, marksEq, } from "./apply.js";
3
- import { normalizeRange, pointEq, textSelection } from "./selection.js";
1
+ import { nodeAt } from "../model/traverse.js";
2
+ import { applyStep } from "./apply.js";
3
+ import { deleteRange } from "./delete-ops.js";
4
+ import { insertNodeInto, insertTextInto, newTextBlock, splitAt, } from "./insert-ops.js";
5
+ import { applyInputRulesTo } from "./input-rules.js";
6
+ import { liftItemOut, sinkItemUnder, splitItemInto } from "./list-ops.js";
7
+ import { resolveInsert } from "./resolve-insert.js";
8
+ import { normalizeRange } from "./selection.js";
9
+ import { insertSliceInto } from "./slice-ops.js";
4
10
  // ── 구현 ────────────────────────────────────────────────────
5
- function pathEq(a, b) {
6
- return a.length === b.length && a.every((v, i) => v === b[i]);
7
- }
8
11
  /**
9
12
  * 트랜잭션 빌더 — EditorState.tr()이 만든다.
10
- * delete() 지원 범위 (Phase 2):
11
- * 같은 노드 안 / 같은 부모 안 형제 / 같은 grandparent 아래 텍스트블록
12
- * 사이(anchor 블록에 focus 잔여 inline을 join). 더 깊은 중첩은 양끝
13
- * trim만 하고 join 없이 둔다 — 스키마 검증과 함께 보강 예정.
14
- */
15
- /**
16
- * 삽입 위치에 상속할 마크 — PM의 MarkSpec.inclusive 관례.
17
- * 마크 run의 가장자리(앞뒤 이웃이 같은 마크를 갖지 않는 경계)에선
18
- * `inclusive: false` 마크를 뗀다 — code 마크 끝에서 타이핑이 스팬
19
- * 밖으로 나오도록. run 안쪽(중간 오프셋, 또는 이웃이 같은 마크인
20
- * 분할 경계)이면 그대로 이어받는다.
13
+ *
14
+ * 복잡한 연산은 전용 모듈에 위임하고 이 클래스는 스텝 큐와
15
+ * 좌표계(docNow)만 유지한다:
16
+ * insert-ops.ts — insertText/insertNode/split + 빈 블록 삽입
17
+ * delete-ops.ts — 임의 범위의 remove/insert 분해
18
+ * list-ops.ts — splitItem/liftItem/sinkItem
19
+ * slice-ops.ts — 붙여넣기 조각 삽입
20
+ * input-rules.ts — 마크다운식 입력 변환
21
+ * resolve-insert.ts — DOM 포인트 → 삽입 좌표 정규화 (순수)
22
+ *
23
+ * 각 모듈은 호스트 인터페이스(ListOpsHost 등)로 이 빌더의 최소
24
+ * 표면만 받는다 — 순환 import 없이 빌더를 재사용한다.
21
25
  */
22
- function marksForInsert(state, doc, p) {
23
- const node = nodeAt(doc, p.path);
24
- const marks = node?.marks;
25
- if (!marks?.length)
26
- return marks;
27
- const parent = nodeAt(doc, p.path.slice(0, -1));
28
- const kids = parent?.content ?? [];
29
- const idx = p.path[p.path.length - 1];
30
- const has = (n, type) => !!n?.marks?.some((m) => m.type === type);
31
- const len = node.text?.length ?? 0;
32
- return marks.filter((m) => {
33
- if (state.schema.markSpec(m.type)?.inclusive !== false)
34
- return true;
35
- if (p.offset === 0)
36
- return has(kids[idx - 1], m.type);
37
- if (p.offset === len)
38
- return has(kids[idx + 1], m.type);
39
- return true;
40
- });
41
- }
42
26
  export class TransactionBuilder {
27
+ // state는 각 *OpsHost 계약의 일부 — 전용 연산 모듈들이
28
+ // 스키마를 조회할 수 있어야 한다. readonly라 외부 변경은 없다.
43
29
  constructor(state) {
44
30
  Object.defineProperty(this, "state", {
45
31
  enumerable: true,
@@ -83,275 +69,43 @@ export class TransactionBuilder {
83
69
  * PM 관례상 "직전 스텝까지 적용된 문서" 좌표다. tr.delete(sel) 뒤
84
70
  * insertText가 삭제된 노드를 가리키는 일이 없게 한다.
85
71
  */
72
+ // *OpsHost 계약 — 전용 연산 모듈들이 현재 문서를 읽는다
86
73
  docNow() {
87
74
  return (this.steps.length ? this.simulate() : this.doc);
88
75
  }
89
- pushRemove(at, node) {
90
- this.steps.push({ op: "remove", at, node });
91
- }
92
- /** atom 리프가 아닌 컨테이너인가 — content가 있거나 spec이 자식을 허용 */
93
- isContainer(n) {
94
- return (n.content !== undefined ||
95
- !!this.state.schema.nodeSpec(n.type)?.content);
96
- }
97
- /** 컨테이너가 인라인 자식을 허용하는가 — 스키마 content 표현 기준 */
98
- acceptsInline(n) {
99
- return /inline/.test(this.state.schema.nodeSpec(n.type)?.content ?? "");
100
- }
101
- /**
102
- * DOM 셀렉션에서 오는 임의 포인트를 텍스트 삽입 가능한 형태로 정규화한다.
103
- * 브라우저는 캐럿을 컨테이너 경계(block 엘리먼트, doc 루트)나
104
- * contenteditable=false 노드(atom) 안쪽에도 놓는다 — 그대로 insert하면
105
- * doc 루트에 bare text 같은 잘못된 구조가 나온다.
106
- * - text : 텍스트 노드 안 포인트 — 문자 오프셋 의미
107
- * - inside : 인라인 전용 빈 컨테이너 — 텍스트 노드를 자식으로 삽입
108
- * - block : 블록 경계 — paragraph로 감싼 블록을 삽입
109
- */
76
+ /** 임의 포인트를 삽입 가능한 좌표로 정규화 (SliceOpsHost 계약) */
110
77
  resolveInsert(doc, p) {
111
- const node = nodeAt(doc, p.path);
112
- if (!node)
113
- return { kind: "block", path: [], offset: 0 };
114
- if (node.type === "text")
115
- return { kind: "text", point: p };
116
- if (!this.isContainer(node)) {
117
- // atom 리프 — 오프셋은 의미가 없으므로 부모 경계(노드 뒤)로 올린다
118
- const idx = p.path[p.path.length - 1];
119
- if (idx == null)
120
- return { kind: "block", path: [], offset: 0 };
121
- return this.resolveInsert(doc, {
122
- path: p.path.slice(0, -1),
123
- offset: idx + 1,
124
- });
125
- }
126
- const children = node.content ?? [];
127
- // 앞으로 탐색 — 경계 뒤 첫 텍스트 후손의 시작
128
- for (let i = p.offset; i < children.length; i++) {
129
- const ftp = firstTextPath(children[i]);
130
- if (ftp) {
131
- return {
132
- kind: "text",
133
- point: { path: [...p.path, i, ...ftp], offset: 0 },
134
- };
135
- }
136
- }
137
- // 뒤로 탐색 — 경계 앞 마지막 텍스트의 끝
138
- for (let i = Math.min(p.offset, children.length) - 1; i >= 0; i--) {
139
- const ltp = lastTextPath(children[i]);
140
- if (ltp) {
141
- const len = nodeAt(children[i], ltp)?.text?.length ?? 0;
142
- return {
143
- kind: "text",
144
- point: { path: [...p.path, i, ...ltp], offset: len },
145
- };
146
- }
147
- }
148
- // 텍스트 후손이 전혀 없다 — 인라인 블록이면 안에, 아니면 새 블록으로
149
- if (p.path.length > 0 && this.acceptsInline(node)) {
150
- return { kind: "inside", path: p.path, offset: p.offset };
151
- }
152
- return { kind: "block", path: p.path, offset: p.offset };
153
- }
154
- /** 기본 텍스트블록 타입 — paragraph, 없으면 inline을 받는 첫 block */
155
- defaultBlockType() {
156
- const spec = this.state.schema.spec.nodes;
157
- return spec["paragraph"]
158
- ? "paragraph"
159
- : (Object.keys(spec).find((t) => spec[t].group?.split(" ").includes("block") &&
160
- /inline/.test(spec[t].content ?? "")) ?? "paragraph");
78
+ return resolveInsert(this.state, doc, p);
161
79
  }
162
- /** 텍스트를 담는 새 블록 — 스키마에 paragraph가 없으면 inline 블록을 찾는다 */
80
+ /** 텍스트를 담는 새 블록 — 스키마에 paragraph가 없으면 inline 블록을 찾는다 (SliceOpsHost 계약) */
163
81
  newTextBlock(children) {
164
- return { type: this.defaultBlockType(), content: [...children] };
165
- }
166
- /** 블록 시작 셀렉션 — 텍스트 자식이 있으면 첫 텍스트 앞, 없으면 블록 안 */
167
- selStartOfBlock(blockPath, block) {
168
- const t = firstTextPath(block);
169
- return textSelection(t
170
- ? { path: [...blockPath, ...t], offset: 0 }
171
- : { path: [...blockPath], offset: 0 });
172
- }
173
- /** 블록 경계에 새 빈 블록을 넣고 캐럿을 그 안에 둔다 */
174
- insertEmptyBlock(insertAt, type) {
175
- this.steps.push({
176
- op: "insert",
177
- at: insertAt,
178
- node: type
179
- ? { type, content: [] }
180
- : this.newTextBlock([]),
181
- });
182
- this.selection = textSelection({
183
- path: [...insertAt.path, insertAt.offset],
184
- offset: 0,
185
- });
186
- return this;
82
+ return newTextBlock(this.state, children);
187
83
  }
84
+ /**
85
+ * 텍스트 삽입 — 구현은 insert-ops.ts. at이 없으면 현재 셀렉션
86
+ * (비축소면 먼저 삭제). 마크 우선순위와 code 블록 무시는
87
+ * insert-ops.ts의 규칙을 따른다.
88
+ */
188
89
  insertText(text, at) {
189
- if (!text)
190
- return this;
191
- let target = at;
192
- if (!target) {
193
- const sel = this.selection ?? this.state.selection;
194
- if (sel.type !== "text")
195
- return this;
196
- const r = normalizeRange(sel);
197
- if (!pointEq(r.anchor, r.focus))
198
- this.delete(r);
199
- target = r.anchor;
200
- }
201
- const doc = this.docNow();
202
- const t = this.resolveInsert(doc, target);
203
- // 마크 우선순위: 이 tr에서 명시한 storedMarks → 이전 tr이 남긴
204
- // state.storedMarks(툴바 토글 등) → 커서 위치 텍스트 마크
205
- const marks = this.storedMarks ??
206
- this.state.storedMarks ??
207
- (t.kind === "text"
208
- ? marksForInsert(this.state, doc, t.point)
209
- : undefined);
210
- const node = {
211
- type: "text",
212
- text,
213
- ...(marks?.length ? { marks } : {}),
214
- };
215
- if (t.kind === "text") {
216
- this.steps.push({ op: "insert", at: t.point, node });
217
- this.selection = this.selectionAfterInsert(t.point, node, nodeAt(doc, t.point.path));
218
- return this;
219
- }
220
- if (t.kind === "inside") {
221
- const at2 = { path: t.path, offset: t.offset };
222
- this.steps.push({ op: "insert", at: at2, node });
223
- this.selection = textSelection({
224
- path: [...t.path, t.offset],
225
- offset: text.length,
226
- });
227
- return this;
228
- }
229
- // 블록 경계 — paragraph로 감싸 삽입 (전체 선택 후 타이핑 등)
230
- const block = this.newTextBlock([node]);
231
- const at2 = { path: t.path, offset: t.offset };
232
- this.steps.push({ op: "insert", at: at2, node: block });
233
- this.selection = textSelection({
234
- path: [...t.path, t.offset, 0],
235
- offset: text.length,
236
- });
90
+ insertTextInto(this, text, at);
237
91
  return this;
238
92
  }
239
- selectionAfterInsert(at, node, target) {
240
- const len = node.text?.length ?? 0;
241
- if (target?.type === "text") {
242
- if (node.type === "text" && marksEq(target.marks, node.marks)) {
243
- return textSelection({ path: at.path, offset: at.offset + len });
244
- }
245
- // 마크가 달라 [head?, 삽입, tail?]로 분할된다 — 삽입 노드는
246
- // head가 있으면 idx+1, 없으면(맨 앞) idx에 놓인다. 캐럿은
247
- // 삽입 노드 끝 — 끝 삽입에서 idx+2 같은 빈 경로를 만들면
248
- // 안 된다 (회귀 — 다음 tr이 무효 경로를 block 폴백으로 해석)
249
- const parent = at.path.slice(0, -1);
250
- const idx = at.path[at.path.length - 1] + (at.offset > 0 ? 1 : 0);
251
- return textSelection({ path: [...parent, idx], offset: len });
252
- }
253
- if (node.type === "text") {
254
- return textSelection({ path: [...at.path, at.offset], offset: len });
255
- }
256
- return textSelection({ path: at.path, offset: at.offset + 1 });
257
- }
93
+ /**
94
+ * 노드 삽입 — at에 그대로 넣고 캐럿은 삽입물 뒤에 둔다.
95
+ * 구현은 insert-ops.ts.
96
+ */
258
97
  insertNode(at, node) {
259
- this.steps.push({ op: "insert", at, node });
260
- this.selection = this.selectionAfterInsert(at, node, nodeAt(this.docNow(), at.path));
98
+ insertNodeInto(this, at, node);
261
99
  return this;
262
100
  }
263
101
  /**
264
102
  * at 위치에서 노드를 분할한다 (Enter). 생략하면 현재 셀렉션 —
265
103
  * 비어있지 않은 셀렉션이면 먼저 지운 뒤 분할한다.
266
104
  * 셀렉션은 분할된 "오른쪽" 부분의 시작으로 옮긴다.
105
+ * 구현은 insert-ops.ts — 비텍스트 포인트 환원과 splitExit 규칙을 포함한다.
267
106
  */
268
107
  split(at, depth = 1) {
269
- let p = at;
270
- if (!p) {
271
- const sel = this.selection ?? this.state.selection;
272
- if (sel.type !== "text")
273
- return this;
274
- const r = normalizeRange(sel);
275
- if (!pointEq(r.anchor, r.focus))
276
- this.delete(r);
277
- p = r.anchor;
278
- }
279
- const doc = this.docNow();
280
- if (nodeAt(doc, p.path)?.type !== "text") {
281
- // 비텍스트 포인트 Enter — 인라인 컨테이너 경계면 가장 가까운
282
- // 텍스트 위치로 환원해 정상 분할하고, 그 외(블록 컨테이너 경계,
283
- // atom 안, 빈 블록)에는 그 자리에 새 빈 블록을 만든다.
284
- const node = nodeAt(doc, p.path);
285
- let insertAt = null;
286
- if (node && this.isContainer(node) && this.acceptsInline(node)) {
287
- const res = this.resolveInsert(doc, p);
288
- if (res.kind === "text") {
289
- p = res.point;
290
- }
291
- else {
292
- // 빈 인라인 블록 — 뒤에 새 블록
293
- insertAt = {
294
- path: p.path.slice(0, -1),
295
- offset: p.path[p.path.length - 1] + 1,
296
- };
297
- }
298
- }
299
- else if (node && this.isContainer(node)) {
300
- // 블록 전용 컨테이너(doc 등) 경계 — 그 위치 그대로
301
- insertAt = p;
302
- }
303
- else {
304
- // atom/리프 안 — 노드 뒤 부모 경계
305
- const idx = p.path[p.path.length - 1];
306
- insertAt =
307
- idx == null
308
- ? p
309
- : { path: p.path.slice(0, -1), offset: idx + 1 };
310
- }
311
- if (insertAt)
312
- return this.insertEmptyBlock(insertAt);
313
- }
314
- // 블록 "끝"에서의 Enter는 splitExit 타입의 블록을 새로 만든다 — PM
315
- // 관례 (heading 끝 Enter → 다음 heading이 아니라 paragraph. 중간
316
- // 분할은 타입을 유지한다). splitExit 미지정 블록은 기본 블록 타입으로.
317
- const target = nodeAt(doc, p.path);
318
- if (target?.type === "text" && p.path.length >= 2) {
319
- const blockPath = p.path.slice(0, -1);
320
- const block = nodeAt(doc, blockPath);
321
- const lastIdx = (block?.content?.length ?? 0) - 1;
322
- const atEnd = p.offset === (target.text ?? "").length &&
323
- p.path[p.path.length - 1] === lastIdx;
324
- const exitType = block
325
- ? (this.state.schema.nodeSpec(block.type)?.splitExit ??
326
- this.defaultBlockType())
327
- : undefined;
328
- if (atEnd && block && exitType && exitType !== block.type) {
329
- return this.insertEmptyBlock({
330
- path: blockPath.slice(0, -1),
331
- offset: blockPath[blockPath.length - 1] + 1,
332
- }, exitType);
333
- }
334
- }
335
- const step = { op: "split", at: p, depth };
336
- this.steps.push(step);
337
- const ops = computeSplitOps(doc, step);
338
- let caret = null;
339
- if (target?.type === "text") {
340
- // 텍스트 분할 뒤 경계(= 오른쪽 텍스트 시작)를 만들어 나머지
341
- // nodeSplit op들로 매핑 — 오른쪽 편향
342
- caret = {
343
- path: p.path.slice(0, -1),
344
- offset: p.path[p.path.length - 1] + 1,
345
- };
346
- for (const op of ops.slice(1))
347
- caret = mapThroughSplitOp(caret, op);
348
- }
349
- else {
350
- caret = p;
351
- for (const op of ops)
352
- caret = mapThroughSplitOp(caret, op);
353
- }
354
- this.selection = textSelection(caret);
108
+ splitAt(this, at, depth);
355
109
  return this;
356
110
  }
357
111
  /**
@@ -360,73 +114,8 @@ export class TransactionBuilder {
360
114
  * caret은 아이템 안의 텍스트 포인트여야 한다.
361
115
  */
362
116
  splitItem(itemPath, caret) {
363
- const doc = this.docNow();
364
- const listPath = itemPath.slice(0, -1);
365
- const k = itemPath[itemPath.length - 1];
366
- const list = k == null ? undefined : nodeAt(doc, listPath);
367
- const item = nodeAt(doc, itemPath);
368
- if (!list?.content || !item?.content || k == null)
369
- return this;
370
- // caret = [...itemPath, childIdx, textIdx]/offset
371
- const childIdx = caret.path[itemPath.length] ?? 0;
372
- const child = item.content[childIdx];
373
- const textIdx = caret.path[itemPath.length + 1] ?? 0;
374
- const textNode = child?.content?.[textIdx];
375
- const headText = textNode?.type === "text" ? (textNode.text ?? "").slice(0, caret.offset) : "";
376
- const tailText = textNode?.type === "text" ? (textNode.text ?? "").slice(caret.offset) : "";
377
- if (child && !childIdx && !textIdx && !headText) {
378
- // 아이템 맨 앞 Enter — 빈 아이템을 위에 삽입 (PM liftEmptyItem과 유사)
379
- const empty = {
380
- type: item.type,
381
- content: [{ type: child.type, content: [] }],
382
- };
383
- this.replaceNode(listPath, {
384
- ...list,
385
- content: [
386
- ...list.content.slice(0, k),
387
- empty,
388
- item,
389
- ...list.content.slice(k + 1),
390
- ],
391
- });
392
- this.selection = this.selStartOfBlock([...listPath, k], empty);
393
- return this;
394
- }
395
- const headChild = child
396
- ? {
397
- ...child,
398
- content: [
399
- ...(child.content ?? []).slice(0, textIdx),
400
- ...(textNode ? [{ ...textNode, text: headText }] : []),
401
- ],
402
- }
403
- : { type: this.defaultBlockType(), content: [] };
404
- const tailChildContent = [
405
- ...(textNode || tailText ? [{ type: "text", text: tailText }] : []),
406
- ...(child?.content?.slice(textIdx + 1) ?? []),
407
- ];
408
- const tailChild = {
409
- type: child?.type ?? this.defaultBlockType(),
410
- content: tailChildContent,
411
- };
412
- const headItem = {
413
- ...item,
414
- content: [...item.content.slice(0, childIdx), headChild],
415
- };
416
- const tailItem = {
417
- type: item.type,
418
- content: [tailChild, ...item.content.slice(childIdx + 1)],
419
- };
420
- this.replaceNode(listPath, {
421
- ...list,
422
- content: [
423
- ...list.content.slice(0, k),
424
- headItem,
425
- tailItem,
426
- ...list.content.slice(k + 1),
427
- ],
428
- });
429
- this.selection = this.selStartOfBlock([...listPath, k + 1], tailItem);
117
+ // 구현은 list-ops.ts — 빌더는 ListOpsHost 계약으로 스텝만 받는다
118
+ splitItemInto(this, this.docNow(), itemPath, caret);
430
119
  return this;
431
120
  }
432
121
  /**
@@ -436,89 +125,7 @@ export class TransactionBuilder {
436
125
  * 끼운다 (PM liftListItem 관례).
437
126
  */
438
127
  liftItem(itemPath) {
439
- const doc = this.docNow();
440
- const listPath = itemPath.slice(0, -1);
441
- const k = itemPath[itemPath.length - 1];
442
- const list = k == null ? undefined : nodeAt(doc, listPath);
443
- const item = nodeAt(doc, itemPath);
444
- const blocks = item?.content ?? [];
445
- if (!list?.content ||
446
- !item ||
447
- !blocks.length ||
448
- k == null ||
449
- !listPath.length) {
450
- return this;
451
- }
452
- const gPath = listPath.slice(0, -1);
453
- const container = gPath.length ? nodeAt(doc, gPath) : undefined;
454
- if (container && this.state.schema.nodeSpec(container.type)?.listItem) {
455
- // 중첩 아이템 — 부모 li 안에 풀지 않고 바깥 리스트의 형제 li로 들어올린다.
456
- // 뒤따르는 형제들은 들어올린 아이템의 서브리스트로 딸려 나간다(PM 관례)
457
- const outerListPath = gPath.slice(0, -1);
458
- const outerIdx = gPath[gPath.length - 1];
459
- const subIdx = listPath[listPath.length - 1];
460
- const trailing = list.content.slice(k + 1);
461
- const before = list.content.slice(0, k);
462
- const lifted = {
463
- ...item,
464
- content: trailing.length
465
- ? [...blocks, { type: list.type, content: trailing }]
466
- : blocks,
467
- };
468
- this.delete({
469
- anchor: { path: listPath, offset: k },
470
- focus: { path: listPath, offset: list.content.length },
471
- });
472
- if (!before.length) {
473
- this.delete({
474
- anchor: { path: gPath, offset: subIdx },
475
- focus: { path: gPath, offset: subIdx + 1 },
476
- });
477
- }
478
- this.insertNode({ path: outerListPath, offset: outerIdx + 1 }, lifted);
479
- this.selection = this.selStartOfBlock([...outerListPath, outerIdx + 1, 0], blocks[0]);
480
- return this;
481
- }
482
- const li = listPath[listPath.length - 1];
483
- const len = list.content.length;
484
- if (len === 1) {
485
- // 유일한 아이템 — 리스트 통째를 아이템 블록들로 교체
486
- this.delete({
487
- anchor: { path: gPath, offset: li },
488
- focus: { path: gPath, offset: li + 1 },
489
- });
490
- for (let i = 0; i < blocks.length; i++) {
491
- this.insertNode({ path: gPath, offset: li + i }, blocks[i]);
492
- }
493
- this.selection = this.selStartOfBlock([...gPath, li], blocks[0]);
494
- return this;
495
- }
496
- if (k === 0 || k === len - 1) {
497
- // 머리 → 리스트 앞 / 꼬리 → 리스트 뒤
498
- const out = k === 0 ? li : li + 1;
499
- this.delete({
500
- anchor: { path: listPath, offset: k },
501
- focus: { path: listPath, offset: k + 1 },
502
- });
503
- for (let i = 0; i < blocks.length; i++) {
504
- this.insertNode({ path: gPath, offset: out + i }, blocks[i]);
505
- }
506
- this.selection = this.selStartOfBlock([...gPath, out], blocks[0]);
507
- return this;
508
- }
509
- // 중간 — 리스트를 [앞], 아이템 블록들, [뒤]로 쪼갠다
510
- const listA = { ...list, content: list.content.slice(0, k) };
511
- const listB = { ...list, content: list.content.slice(k + 1) };
512
- this.delete({
513
- anchor: { path: gPath, offset: li },
514
- focus: { path: gPath, offset: li + 1 },
515
- });
516
- let off = li;
517
- this.insertNode({ path: gPath, offset: off++ }, listA);
518
- for (const b of blocks)
519
- this.insertNode({ path: gPath, offset: off++ }, b);
520
- this.insertNode({ path: gPath, offset: off }, listB);
521
- this.selection = this.selStartOfBlock([...gPath, li + 1], blocks[0]);
128
+ liftItemOut(this, this.docNow(), itemPath);
522
129
  return this;
523
130
  }
524
131
  /**
@@ -526,47 +133,7 @@ export class TransactionBuilder {
526
133
  * 자식이 같은 타입 리스트면 그 끝에, 아니면 새 리스트를 만들어 넣는다.
527
134
  */
528
135
  sinkItem(itemPath) {
529
- const doc = this.docNow();
530
- const listPath = itemPath.slice(0, -1);
531
- const k = itemPath[itemPath.length - 1];
532
- const list = k == null ? undefined : nodeAt(doc, listPath);
533
- const item = nodeAt(doc, itemPath);
534
- if (!list?.content || !item || k == null || k === 0)
535
- return this;
536
- const prev = list.content[k - 1];
537
- const prevContent = prev.content ?? [];
538
- const last = prevContent[prevContent.length - 1];
539
- let childIdx;
540
- let subIdx;
541
- let newPrev;
542
- if (last?.type === list.type && last.content !== undefined) {
543
- childIdx = prevContent.length - 1;
544
- subIdx = last.content.length;
545
- newPrev = {
546
- ...prev,
547
- content: [
548
- ...prevContent.slice(0, -1),
549
- { ...last, content: [...last.content, item] },
550
- ],
551
- };
552
- }
553
- else {
554
- childIdx = prevContent.length;
555
- subIdx = 0;
556
- newPrev = {
557
- ...prev,
558
- content: [...prevContent, { type: list.type, content: [item] }],
559
- };
560
- }
561
- this.replaceNode(listPath, {
562
- ...list,
563
- content: [
564
- ...list.content.slice(0, k - 1),
565
- newPrev,
566
- ...list.content.slice(k + 1),
567
- ],
568
- });
569
- this.selection = this.selStartOfBlock([...listPath, k - 1, childIdx, subIdx], item);
136
+ sinkItemUnder(this, this.docNow(), itemPath);
570
137
  return this;
571
138
  }
572
139
  replaceNode(path, node) {
@@ -583,153 +150,21 @@ export class TransactionBuilder {
583
150
  this.steps.push(step);
584
151
  return this;
585
152
  }
153
+ /** SliceOpsHost 계약 — 스키마 기준 인라인 노드 판정 */
586
154
  isInlineNode(n) {
587
155
  return n.type === "text" || this.state.schema.isInline(n.type);
588
156
  }
157
+ /** SliceOpsHost 계약 — 인라인만 담는 텍스트블록 판정 */
589
158
  isTextBlock(n) {
590
159
  return (!this.isInlineNode(n) &&
591
160
  (n.content ?? []).length > 0 &&
592
161
  (n.content ?? []).every((c) => this.isInlineNode(c)));
593
162
  }
594
163
  insertSlice(at, nodes) {
595
- if (!nodes.length)
596
- return this;
597
- const doc = this.docNow();
598
- if (nodeAt(doc, at.path)?.type !== "text") {
599
- // 컨테이너 경계/atom 안 포인트 — 텍스트 위치로 해석해 수준을 맞춘다
600
- const r = this.resolveInsert(doc, at);
601
- if (r.kind === "text") {
602
- at = r.point;
603
- }
604
- else if (r.kind === "inside") {
605
- at = nodes.every((n) => this.isInlineNode(n))
606
- ? { path: r.path, offset: r.offset }
607
- : // 블록은 인라인 컨테이너 안에 못 넣는다 — 블록 뒤 경계로 승격
608
- {
609
- path: r.path.slice(0, -1),
610
- offset: r.path[r.path.length - 1] + 1,
611
- };
612
- }
613
- else {
614
- const at2 = { path: r.path, offset: r.offset };
615
- // doc 루트 등 블록 경계 — 인라인 조각은 paragraph로 감싼다
616
- return nodes.every((n) => this.isInlineNode(n))
617
- ? this.insertBlockSlice(doc, at2, [this.newTextBlock(nodes)])
618
- : this.insertBlockSlice(doc, at2, nodes);
619
- }
620
- }
621
- const target = nodeAt(doc, at.path);
622
- // 문단 하나짜리 붙여넣기 — 인라인으로 풀어 삽입 (PM slice 관례)
623
- if (nodes.length === 1 &&
624
- target?.type === "text" &&
625
- this.isTextBlock(nodes[0])) {
626
- return this.insertInlineSlice(at, nodes[0].content ?? []);
627
- }
628
- if (nodes.every((n) => this.isInlineNode(n))) {
629
- return this.insertInlineSlice(at, nodes);
630
- }
631
- return this.insertBlockSlice(doc, at, nodes);
632
- }
633
- /** 인라인 조각 — 텍스트를 o에서 나누고 사이에 끼운다 */
634
- insertInlineSlice(at, nodes) {
635
- const target = nodeAt(this.docNow(), at.path);
636
- if (target?.type === "text") {
637
- const parent = at.path.slice(0, -1);
638
- const i = at.path[at.path.length - 1];
639
- const len = target.text?.length ?? 0;
640
- let base;
641
- let caretAfter;
642
- if (at.offset === 0) {
643
- base = i;
644
- caretAfter = { path: [...parent, i + nodes.length], offset: 0 };
645
- }
646
- else if (at.offset >= len) {
647
- base = i + 1;
648
- caretAfter = { path: [...parent, i + 1 + nodes.length], offset: 0 };
649
- }
650
- else {
651
- this.steps.push({ op: "split", at, depth: 0 });
652
- base = i + 1;
653
- caretAfter = { path: [...parent, i + 1 + nodes.length], offset: 0 };
654
- }
655
- nodes.forEach((node, j) => {
656
- this.steps.push({
657
- op: "insert",
658
- at: { path: parent, offset: base + j },
659
- node,
660
- });
661
- });
662
- this.selection = textSelection(caretAfter);
663
- return this;
664
- }
665
- // 컨테이너 경계
666
- nodes.forEach((node, j) => {
667
- this.steps.push({
668
- op: "insert",
669
- at: { path: at.path, offset: at.offset + j },
670
- node,
671
- });
672
- });
673
- this.selection = textSelection({
674
- path: at.path,
675
- offset: at.offset + nodes.length,
676
- });
677
- return this;
678
- }
679
- /** 블록 조각 — 현재 블록을 나누고 사이에 삽입 (doc 최상위 기준) */
680
- insertBlockSlice(doc, at, nodes) {
681
- const target = nodeAt(doc, at.path);
682
- if (target?.type === "text") {
683
- const bi = at.path[0];
684
- if (bi == null)
685
- return this;
686
- // 텍스트 + 상위 체인을 전부 나눠 최상위 블록을 둘로 만든다
687
- this.steps.push({ op: "split", at, depth: at.path.length - 1 });
688
- const insertAt = bi + 1;
689
- nodes.forEach((node, j) => {
690
- this.steps.push({
691
- op: "insert",
692
- at: { path: [], offset: insertAt + j },
693
- node,
694
- });
695
- });
696
- // 캐럿 = 오른쪽 부분 블록(bi+1+n)의 첫 텍스트, 없으면 마지막 삽입 끝
697
- const sim = this.simulate();
698
- const right = nodeAt(sim, [insertAt + nodes.length]);
699
- const ftp = right ? firstTextPath(right) : null;
700
- if (ftp) {
701
- this.selection = textSelection({
702
- path: [insertAt + nodes.length, ...ftp],
703
- offset: 0,
704
- });
705
- }
706
- else {
707
- const last = nodes[nodes.length - 1];
708
- const ltp = last ? lastTextPath(last) : null;
709
- this.selection = ltp
710
- ? textSelection({
711
- path: [insertAt + nodes.length - 1, ...ltp],
712
- offset: (nodeAt(last, ltp)?.text ?? "").length,
713
- })
714
- : textSelection({ path: [insertAt], offset: 0 });
715
- }
716
- return this;
717
- }
718
- // 컨테이너 경계 — 같은 부모 안에 순서대로 삽입
719
- nodes.forEach((node, j) => {
720
- this.steps.push({
721
- op: "insert",
722
- at: { path: at.path, offset: at.offset + j },
723
- node,
724
- });
725
- });
726
- this.selection = textSelection({
727
- path: at.path,
728
- offset: at.offset + nodes.length,
729
- });
164
+ insertSliceInto(this, at, nodes);
730
165
  return this;
731
166
  }
732
- /** 지금까지 쌓인 스텝을 doc에 적용한 결과 — 캐럿 계산용 */
167
+ /** 지금까지 쌓인 스텝을 doc에 적용한 결과 — 캐럿 계산용 (SliceOpsHost 계약) */
733
168
  simulate() {
734
169
  let d = this.doc;
735
170
  for (const s of this.steps)
@@ -737,200 +172,24 @@ export class TransactionBuilder {
737
172
  return d;
738
173
  }
739
174
  /**
740
- * 문서 순서 비교 — pointCmp와 다른 점: 컨테이너 경계 포인트를
741
- * "자식 offset 앞"으로 해석한다. pointCmp는 [i]/k < [i,j]/m이지만
742
- * 실제 문서 순서에선 [i]/k 는 k>j 이면 [i,j] 안쪽 "뒤"다 —
743
- * DOM 셀렉션이 만드는 (블록엘리먼트, 자식인덱스) 포인트를 정렬할 때
744
- * pointCmp를 쓰면 범위가 뒤집힌다.
175
+ * 범위 삭제 — 구현은 delete-ops.ts. anchor/focus 순서와 atom 경계를
176
+ * 정규화하고, LCA 레벨 삭제 + 양 끝 재귀 trim으로 임의 범위를
177
+ * remove/insert 스텝 열로 분해한다.
745
178
  */
746
- docOrderCmp(a, b) {
747
- const d = Math.min(a.path.length, b.path.length);
748
- for (let i = 0; i < d; i++) {
749
- if (a.path[i] !== b.path[i])
750
- return a.path[i] - b.path[i];
751
- }
752
- if (a.path.length === b.path.length)
753
- return a.offset - b.offset;
754
- if (a.path.length < b.path.length) {
755
- return b.path[a.path.length] < a.offset ? 1 : -1;
756
- }
757
- return a.path[b.path.length] < b.offset ? -1 : 1;
758
- }
759
- /** atom 리프 안 포인트를 부모 경계로 올린다 — dir>0이면 노드 "뒤" 경계 */
760
- resolveBoundary(doc, p, dir) {
761
- const node = nodeAt(doc, p.path);
762
- if (!node || node.type === "text" || this.isContainer(node))
763
- return p;
764
- const idx = p.path[p.path.length - 1];
765
- if (idx == null)
766
- return p;
767
- return {
768
- path: p.path.slice(0, -1),
769
- offset: idx + (dir > 0 ? 1 : 0),
770
- };
771
- }
772
179
  delete(range) {
773
- const doc = this.docNow();
774
- const fwd = this.docOrderCmp(range.anchor, range.focus) <= 0;
775
- const a = this.resolveBoundary(doc, fwd ? range.anchor : range.focus, -1);
776
- const f = this.resolveBoundary(doc, fwd ? range.focus : range.anchor, 1);
777
- return this.deleteOrdered(doc, a, f);
778
- }
779
- /** a<f로 정렬 + atom 경계 해석이 끝난 범위 삭제 — 재귀용 내부 구현 */
780
- deleteOrdered(doc, a, f) {
781
- if (pointEq(a, f))
782
- return this;
783
- const aNode = nodeAt(doc, a.path);
784
- const fNode = nodeAt(doc, f.path);
785
- // 같은 노드 안
786
- if (pathEq(a.path, f.path)) {
787
- if (aNode?.type === "text") {
788
- this.pushRemove(a, {
789
- type: "text",
790
- text: (aNode.text ?? "").slice(a.offset, f.offset),
791
- ...(aNode.marks && { marks: aNode.marks }),
792
- });
793
- }
794
- else if (aNode?.content) {
795
- for (let j = f.offset - 1; j >= a.offset; j--) {
796
- const child = aNode.content[j];
797
- if (child)
798
- this.pushRemove({ path: a.path, offset: j }, child);
799
- }
800
- }
801
- return this.setSelection(textSelection(a));
802
- }
803
- const aParent = a.path.slice(0, -1);
804
- const fParent = f.path.slice(0, -1);
805
- // 같은 부모 안 형제 사이
806
- if (pathEq(aParent, fParent)) {
807
- if (fNode?.type === "text" && f.offset > 0) {
808
- this.pushRemove({ path: f.path, offset: 0 }, {
809
- type: "text",
810
- text: fNode.text.slice(0, f.offset),
811
- ...(fNode.marks && { marks: fNode.marks }),
812
- });
813
- }
814
- const ai = a.path[a.path.length - 1];
815
- const fi = f.path[f.path.length - 1];
816
- for (let j = fi - 1; j > ai; j--) {
817
- const child = nodeAt(doc, [...aParent, j]);
818
- if (child)
819
- this.pushRemove({ path: aParent, offset: j }, child);
820
- }
821
- if (aNode?.type === "text") {
822
- const tail = (aNode.text ?? "").slice(a.offset);
823
- if (tail) {
824
- this.pushRemove(a, {
825
- type: "text",
826
- text: tail,
827
- ...(aNode.marks && { marks: aNode.marks }),
828
- });
829
- }
830
- }
831
- return this.setSelection(textSelection(a));
832
- }
833
- // 다른 블록 — 같은 grandparent 아래 텍스트블록 사이면 join한다
834
- const aGrand = aParent.slice(0, -1);
835
- const fGrand = fParent.slice(0, -1);
836
- const joinable = pathEq(aGrand, fGrand) &&
837
- aParent.length === fParent.length &&
838
- aNode?.type === "text" &&
839
- fNode?.type === "text";
840
- if (joinable) {
841
- const ai = aParent[aParent.length - 1];
842
- const fi = fParent[fParent.length - 1];
843
- const ti = a.path[a.path.length - 1];
844
- const tj = f.path[f.path.length - 1];
845
- const aBlock = nodeAt(doc, aParent);
846
- const fBlock = nodeAt(doc, fParent);
847
- // anchor 텍스트 tail 제거
848
- const aTail = aNode.text.slice(a.offset);
849
- if (aTail) {
850
- this.pushRemove(a, {
851
- type: "text",
852
- text: aTail,
853
- ...(aNode.marks && { marks: aNode.marks }),
854
- });
855
- }
856
- // anchor 블록의 뒤쪽 형제 제거 (desc)
857
- const aSibs = aBlock.content ?? [];
858
- for (let j = aSibs.length - 1; j > ti; j--) {
859
- this.pushRemove({ path: aParent, offset: j }, aSibs[j]);
860
- }
861
- // focus 잔여분을 anchor 블록에 join
862
- const fTail = fNode.text.slice(f.offset);
863
- let insAt = ti + 1;
864
- if (fTail) {
865
- this.steps.push({
866
- op: "insert",
867
- at: { path: aParent, offset: insAt++ },
868
- node: {
869
- type: "text",
870
- text: fTail,
871
- ...(fNode.marks && { marks: fNode.marks }),
872
- },
873
- });
874
- }
875
- const fSibs = fBlock.content ?? [];
876
- for (let j = tj + 1; j < fSibs.length; j++) {
877
- this.steps.push({
878
- op: "insert",
879
- at: { path: aParent, offset: insAt++ },
880
- node: fSibs[j],
881
- });
882
- }
883
- // focus 블록 + 중간 블록 제거 (desc index — anchor는 항상 앞)
884
- for (let j = fi; j > ai; j--) {
885
- const blk = nodeAt(doc, [...aGrand, j]);
886
- if (blk)
887
- this.pushRemove({ path: aGrand, offset: j }, blk);
888
- }
889
- return this.setSelection(textSelection(a));
890
- }
891
- // 일반화 — LCA 레벨에서 완전히 선택된 자식을 제거하고, 양 끝은
892
- // 각각 "자식 끝까지"/"자식 처음부터" 범위로 재귀 trim한다.
893
- // DOM 셀렉션이 만드는 경계↔내부 혼합 범위(전체 선택, atom 걸침)를 커버.
894
- let l = 0;
895
- const minLen = Math.min(a.path.length, f.path.length);
896
- while (l < minLen && a.path[l] === f.path[l])
897
- l++;
898
- const lca = a.path.slice(0, l);
899
- const aIn = a.path.length > l;
900
- const fIn = f.path.length > l;
901
- const aChild = aIn ? a.path[l] : a.offset;
902
- const fChild = fIn ? f.path[l] : f.offset;
903
- const remStart = aIn ? aChild + 1 : a.offset;
904
- const remEnd = fIn ? fChild : f.offset;
905
- // 끝쪽 부분 삭제 — f가 담긴 자식의 처음부터 f까지
906
- if (fIn) {
907
- this.deleteOrdered(doc, { path: [...lca, fChild], offset: 0 }, f);
908
- }
909
- // 시작쪽 부분 삭제 — a부터 a가 담긴 자식의 끝까지
910
- // (텍스트 자식의 끝은 문자 길이, 컨테이너는 자식 수)
911
- if (aIn) {
912
- const aChildPath = [...lca, aChild];
913
- const aChildNode = nodeAt(doc, aChildPath);
914
- const endOffset = aChildNode?.type === "text"
915
- ? (aChildNode.text?.length ?? 0)
916
- : (aChildNode?.content?.length ?? 0);
917
- this.deleteOrdered(doc, a, { path: aChildPath, offset: endOffset });
918
- }
919
- // 완전 삭제 구간 — LCA의 자식 [remStart, remEnd)을 역순으로
920
- const lcaNode = nodeAt(doc, lca);
921
- for (let j = remEnd - 1; j >= remStart; j--) {
922
- const child = lcaNode?.content?.[j];
923
- if (child)
924
- this.pushRemove({ path: lca, offset: j }, child);
925
- }
926
- return this.setSelection(textSelection(a));
180
+ deleteRange(this, range);
181
+ return this;
927
182
  }
928
183
  addMark(range, mark) {
929
184
  this.steps.push({ op: "addMark", range: normalizeRange(range), mark });
930
185
  return this;
931
186
  }
932
187
  removeMark(range, markType) {
933
- this.steps.push({ op: "removeMark", range: normalizeRange(range), markType });
188
+ this.steps.push({
189
+ op: "removeMark",
190
+ range: normalizeRange(range),
191
+ markType,
192
+ });
934
193
  return this;
935
194
  }
936
195
  setNodeAttr(path, key, value) {
@@ -947,66 +206,11 @@ export class TransactionBuilder {
947
206
  return this;
948
207
  }
949
208
  /**
950
- * 입력 규칙 — 마크다운식 변환(`# `→heading, `**x**`→bold).
951
- * 캐럿이 속한 인라인 블록의 블록 시작~커서 텍스트를 만들어 각
952
- * 규칙의 pattern에 매칭한다. 매치가 커서에서 끝나는 첫 규칙이
953
- * 이 tr 위에 변환 스텝을 쌓는다 — 위치는 docNow 좌표라 규칙
954
- * 핸들러가 쌓은 스텝도 직전 스텝 기준으로 해석된다.
209
+ * 입력 규칙 — 구현은 input-rules.ts. 캐럿이 멈춘 텍스트블록의
210
+ * 블록 시작~커서 텍스트에 규칙을 순서대로 적용한다.
955
211
  */
956
212
  applyInputRules(rules) {
957
- if (!rules.length)
958
- return this;
959
- const sel = this.selection ?? this.state.selection;
960
- if (sel.type !== "text" || !pointEq(sel.anchor, sel.focus))
961
- return this;
962
- const doc = this.docNow();
963
- const caret = sel.anchor;
964
- const node = nodeAt(doc, caret.path);
965
- if (node?.type !== "text")
966
- return this;
967
- const blockPath = caret.path.slice(0, -1);
968
- const block = nodeAt(doc, blockPath);
969
- if (!block?.content || !this.acceptsInline(block))
970
- return this;
971
- // 코드 블록 안에서는 마크다운 변환을 하지 않는다
972
- if (this.state.schema.nodeSpec(block.type)?.code)
973
- return this;
974
- const kids = block.content;
975
- const ci = caret.path[caret.path.length - 1];
976
- // 블록 시작~커서의 인라인 텍스트 + 각 세그먼트의 절대 오프셋
977
- let textBefore = "";
978
- const segs = [];
979
- for (let i = 0; i <= ci && i < kids.length; i++) {
980
- const t = kids[i]?.text ?? "";
981
- const take = i === ci ? Math.min(caret.offset, t.length) : t.length;
982
- segs.push({ path: [...blockPath, i], start: textBefore.length, len: take });
983
- textBefore += t.slice(0, take);
984
- }
985
- if (!textBefore)
986
- return this;
987
- // textBefore 절대 오프셋 → Point (끝은 항상 캐럿)
988
- const toPoint = (pos) => {
989
- for (const s of segs) {
990
- if (!s.len)
991
- continue;
992
- if (pos < s.start + s.len || pos === s.start) {
993
- return { path: s.path, offset: pos - s.start };
994
- }
995
- }
996
- return caret;
997
- };
998
- for (const rule of rules) {
999
- const m = rule.pattern.exec(textBefore);
1000
- if (!m)
1001
- continue;
1002
- const start = m.index;
1003
- const end = start + m[0].length;
1004
- if (end !== textBefore.length)
1005
- continue; // 커서에서 끝나는 매치만
1006
- const handled = rule.handler({ state: this.state, tr: this, dispatch: () => { } }, m, { anchor: toPoint(start), focus: caret }, doc);
1007
- if (handled !== false)
1008
- break;
1009
- }
213
+ applyInputRulesTo(this, rules);
1010
214
  return this;
1011
215
  }
1012
216
  setMeta(key, value) {