@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
@@ -0,0 +1,676 @@
1
+ import { expect, it } from "vitest";
2
+ import { createEditor } from "../editor/editor.js";
3
+ import { createDoc, createText } from "../model/node.js";
4
+ import { parseHTML } from "../parse/html.js";
5
+ import { starterExtensions } from "../presets/starter.js";
6
+ import { leafPoint } from "../state/apply.js";
7
+ import { point, pointEq, textSelection } from "../state/selection.js";
8
+ import { specEditor } from "./fixtures.js";
9
+ /**
10
+ * 입력 계약 — beforeinput 경로(붙여넣기/드롭/삭제/format系/입력 규칙)와
11
+ * 키다운 경로(Enter/Tab/Mod-a)가 모두 트랜잭션으로 수렴하는지 검증한다.
12
+ * 네이티브 DOM 우회를 막는 것이 목적 — doc과 DOM이 어긋나는 회귀 방지.
13
+ */
14
+ export function editorInputSpec(mount) {
15
+ it("HTML 붙여넣기는 sanitize+파싱되어 인라인으로 삽입된다", async () => {
16
+ const editor = specEditor();
17
+ const { el, flush, destroy } = await mount({ editor });
18
+ const nodes = parseHTML(`<p><b>외부</b>텍스트<script>alert(1)</script><i>기울임</i></p>`, editor.state.schema);
19
+ editor.dispatch(editor.state
20
+ .tr()
21
+ .setSelection(textSelection(point([1, 0], 2)))
22
+ .insertSlice(point([1, 0], 2), nodes));
23
+ await flush?.();
24
+ const para = el.querySelector("[data-me-path='1']");
25
+ // 스크립트는 걸러지고 마크는 보존된다
26
+ expect(para.textContent).toBe("둘째외부텍스트기울임");
27
+ expect(para.querySelector("strong")?.textContent).toBe("외부");
28
+ expect(para.querySelector("em")?.textContent).toBe("기울임");
29
+ expect(para.querySelector("script")).toBeNull();
30
+ destroy();
31
+ });
32
+ it("마크다운 텍스트 붙여넣기는 doc 구조로 파싱되어 삽입된다", async () => {
33
+ // text/plain만 있고 마크다운 신호가 있으면 markdownFeature가
34
+ // 파싱해 insertSlice한다 — 5개 렌더러가 같은 구조를 렌더해야 한다
35
+ const editor = createEditor({
36
+ doc: createDoc([{ type: "paragraph", content: [createText("ab")] }]),
37
+ extensions: starterExtensions(),
38
+ });
39
+ const { el, flush, destroy } = await mount({ editor });
40
+ const doc = el.ownerDocument;
41
+ const editable = el.querySelector(".me-editor");
42
+ const holder = editable.querySelector("[data-me-path='0,0']");
43
+ const walker = doc.createTreeWalker(holder, 4);
44
+ let t = null;
45
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
46
+ if (n.textContent)
47
+ t = n;
48
+ }
49
+ doc.getSelection().setBaseAndExtent(t, 2, t, 2);
50
+ const ev = new Event("beforeinput", {
51
+ bubbles: true,
52
+ cancelable: true,
53
+ });
54
+ Object.defineProperties(ev, {
55
+ inputType: { value: "insertFromPaste" },
56
+ dataTransfer: {
57
+ value: {
58
+ getData: (fmt) => fmt === "text/plain" ? "## 제목\n\n- 첫째\n- 둘째" : "",
59
+ },
60
+ },
61
+ });
62
+ editable.dispatchEvent(ev);
63
+ await flush?.();
64
+ // 문단이 나뉘고 제목 + 리스트가 사이에 들어간다
65
+ expect(el.querySelector("h2")).toBeTruthy();
66
+ expect(el.querySelectorAll("ul > li").length).toBe(2);
67
+ destroy();
68
+ });
69
+ it("평문 붙여넣기는 마크다운 변환 없이 그대로 삽입된다", async () => {
70
+ // 미종결 구분자(`**`만)가 있는 텍스트도 평문으로 들어가야 한다
71
+ const editor = createEditor({
72
+ doc: createDoc([{ type: "paragraph", content: [createText("ab")] }]),
73
+ extensions: starterExtensions(),
74
+ });
75
+ const { el, flush, destroy } = await mount({ editor });
76
+ const doc = el.ownerDocument;
77
+ const editable = el.querySelector(".me-editor");
78
+ const holder = editable.querySelector("[data-me-path='0,0']");
79
+ const walker = doc.createTreeWalker(holder, 4);
80
+ let t = null;
81
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
82
+ if (n.textContent)
83
+ t = n;
84
+ }
85
+ doc.getSelection().setBaseAndExtent(t, 2, t, 2);
86
+ const ev = new Event("beforeinput", {
87
+ bubbles: true,
88
+ cancelable: true,
89
+ });
90
+ Object.defineProperties(ev, {
91
+ inputType: { value: "insertFromPaste" },
92
+ dataTransfer: {
93
+ value: {
94
+ getData: (fmt) => fmt === "text/plain" ? "그냥 **텍스트" : "",
95
+ },
96
+ },
97
+ });
98
+ editable.dispatchEvent(ev);
99
+ await flush?.();
100
+ expect(el.querySelector("p").textContent).toBe("ab그냥 **텍스트");
101
+ expect(el.querySelector("strong")).toBeNull();
102
+ destroy();
103
+ });
104
+ it("마크다운 입력 규칙 — `## `는 제목으로, `**x**`는 굵게로 변환된다", async () => {
105
+ // beforeinput → insertText → applyInputRules의 전체 배선을 검증한다.
106
+ // happy-dom에는 InputEvent가 없어 프로퍼티를 직접 박은 Event로 대신한다
107
+ const type = async (el, text, data) => {
108
+ const doc = el.ownerDocument;
109
+ const editable = el.querySelector(".me-editor");
110
+ const holder = editable.querySelector("[data-me-path='0,0']");
111
+ const walker = doc.createTreeWalker(holder, 4 /* SHOW_TEXT */);
112
+ let t = null;
113
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
114
+ if (n.textContent === text)
115
+ t = n;
116
+ }
117
+ doc.getSelection().setBaseAndExtent(t, text.length, t, text.length);
118
+ const ev = new Event("beforeinput", {
119
+ bubbles: true,
120
+ cancelable: true,
121
+ });
122
+ Object.defineProperties(ev, {
123
+ inputType: { value: "insertText" },
124
+ data: { value: data },
125
+ });
126
+ editable.dispatchEvent(ev);
127
+ };
128
+ const editor = createEditor({
129
+ doc: createDoc([{ type: "paragraph", content: [createText("##")] }]),
130
+ extensions: starterExtensions(),
131
+ });
132
+ const { el, flush, destroy } = await mount({ editor });
133
+ await type(el, "##", " ");
134
+ await flush?.();
135
+ const h2 = el.querySelector(".me-editor > [data-me-path='0']");
136
+ expect(h2.tagName.toLowerCase()).toBe("h2");
137
+ expect(h2.textContent).toBe("");
138
+ destroy();
139
+ const editor2 = createEditor({
140
+ doc: createDoc([{ type: "paragraph", content: [createText("**x*")] }]),
141
+ extensions: starterExtensions(),
142
+ });
143
+ const m2 = await mount({ editor: editor2 });
144
+ await type(m2.el, "**x*", "*");
145
+ await m2.flush?.();
146
+ const strong = m2.el.querySelector(".me-editor > [data-me-path='0'] strong");
147
+ expect(strong?.textContent).toBe("x");
148
+ m2.destroy();
149
+ });
150
+ it("래핑 입력 규칙 — `- `는 ul, `> `는 blockquote, ``` 는 pre", async () => {
151
+ // beforeinput 경로 — 마크 규칙 계약과 같은 시뮬레이션
152
+ const type = async (el, text, data) => {
153
+ const doc = el.ownerDocument;
154
+ const editable = el.querySelector(".me-editor");
155
+ const holder = editable.querySelector("[data-me-path='0,0']");
156
+ const walker = doc.createTreeWalker(holder, 4 /* SHOW_TEXT */);
157
+ let t = null;
158
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
159
+ if (n.textContent === text)
160
+ t = n;
161
+ }
162
+ doc.getSelection().setBaseAndExtent(t, text.length, t, text.length);
163
+ const ev = new Event("beforeinput", {
164
+ bubbles: true,
165
+ cancelable: true,
166
+ });
167
+ Object.defineProperties(ev, {
168
+ inputType: { value: "insertText" },
169
+ data: { value: data },
170
+ });
171
+ editable.dispatchEvent(ev);
172
+ };
173
+ const cases = [
174
+ ["-", "ul[data-me-path='0'] > li > p"],
175
+ [">", "blockquote[data-me-path='0'] > p"],
176
+ ];
177
+ for (const [typed, sel] of cases) {
178
+ const editor = createEditor({
179
+ doc: createDoc([{ type: "paragraph", content: [createText(typed)] }]),
180
+ extensions: starterExtensions(),
181
+ });
182
+ const { el, flush, destroy } = await mount({ editor });
183
+ await type(el, typed, " ");
184
+ await flush?.();
185
+ expect(el.querySelector(sel)).toBeTruthy();
186
+ destroy();
187
+ }
188
+ // ``` + 스페이스 → pre (Tiptap 관례 — 공백이 트리거)
189
+ const editor = createEditor({
190
+ doc: createDoc([{ type: "paragraph", content: [createText("```")] }]),
191
+ extensions: starterExtensions(),
192
+ });
193
+ const { el, flush, destroy } = await mount({ editor });
194
+ await type(el, "```", " ");
195
+ await flush?.();
196
+ expect(el.querySelector("pre[data-me-path='0']")).toBeTruthy();
197
+ destroy();
198
+ });
199
+ it("리스트 아이템 Enter — 새 항목, 빈 항목 Enter — 리스트 탈출", async () => {
200
+ const fireEnter = async (el, holderSel) => {
201
+ const doc = el.ownerDocument;
202
+ const editable = el.querySelector(".me-editor");
203
+ const holder = editable.querySelector(holderSel);
204
+ // 내용 있는 텍스트 노드가 있으면 그 끝, 없으면(빈 문단) 홀더 앞
205
+ const walker = doc.createTreeWalker(holder, 4);
206
+ let t = null;
207
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
208
+ if (n.textContent)
209
+ t = n;
210
+ }
211
+ const sel = doc.getSelection();
212
+ if (t)
213
+ sel.setBaseAndExtent(t, t.textContent.length, t, t.textContent.length);
214
+ else
215
+ sel.setBaseAndExtent(holder, 0, holder, 0);
216
+ const ev = new Event("beforeinput", {
217
+ bubbles: true,
218
+ cancelable: true,
219
+ });
220
+ Object.defineProperty(ev, "inputType", {
221
+ value: "insertParagraph",
222
+ });
223
+ editable.dispatchEvent(ev);
224
+ };
225
+ const mk = () => createEditor({
226
+ doc: createDoc([
227
+ {
228
+ type: "bulletList",
229
+ content: [
230
+ {
231
+ type: "listItem",
232
+ content: [{ type: "paragraph", content: [createText("가")] }],
233
+ },
234
+ ],
235
+ },
236
+ ]),
237
+ extensions: starterExtensions(),
238
+ });
239
+ // 아이템 끝 Enter → 같은 리스트에 새 아이템
240
+ {
241
+ const editor = mk();
242
+ const { el, flush, destroy } = await mount({ editor });
243
+ await fireEnter(el, "[data-me-path='0,0,0,0']");
244
+ await flush?.();
245
+ expect(el.querySelectorAll("ul > li").length).toBe(2);
246
+ destroy();
247
+ }
248
+ // 빈 아이템 Enter → 아이템이 리스트 밖 문단으로 빠진다
249
+ {
250
+ const editor = createEditor({
251
+ doc: createDoc([
252
+ {
253
+ type: "bulletList",
254
+ content: [
255
+ {
256
+ type: "listItem",
257
+ content: [{ type: "paragraph", content: [createText("")] }],
258
+ },
259
+ ],
260
+ },
261
+ ]),
262
+ extensions: starterExtensions(),
263
+ });
264
+ const { el, flush, destroy } = await mount({ editor });
265
+ await fireEnter(el, "[data-me-path='0,0,0,0']");
266
+ await flush?.();
267
+ expect(el.querySelector("ul")).toBeNull();
268
+ expect(el.querySelector(".me-editor > p[data-me-path='0']")).toBeTruthy();
269
+ destroy();
270
+ }
271
+ });
272
+ it("Shift-Enter는 하드 브레이크를 삽입한다", async () => {
273
+ const editor = createEditor({
274
+ doc: createDoc([{ type: "paragraph", content: [createText("ab")] }]),
275
+ extensions: starterExtensions(),
276
+ });
277
+ const { el, flush, destroy } = await mount({ editor });
278
+ const editable = el.querySelector(".me-editor");
279
+ const doc = el.ownerDocument;
280
+ const holder = editable.querySelector("[data-me-path='0,0']");
281
+ const walker = doc.createTreeWalker(holder, 4);
282
+ let t = null;
283
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
284
+ if (n.textContent === "ab")
285
+ t = n;
286
+ }
287
+ doc.getSelection().setBaseAndExtent(t, 1, t, 1);
288
+ editable.dispatchEvent(new KeyboardEvent("keydown", {
289
+ key: "Enter",
290
+ shiftKey: true,
291
+ bubbles: true,
292
+ cancelable: true,
293
+ }));
294
+ await flush?.();
295
+ expect(el.querySelector("br[data-me-path='0,1']")).toBeTruthy();
296
+ const kids = editor.state.doc.content[0].content;
297
+ expect(kids.map((k) => k.type)).toEqual(["text", "hardBreak", "text"]);
298
+ destroy();
299
+ });
300
+ it("Mod-a는 doc 전체 선택을 만든다 — 중첩 editable이 있어도", async () => {
301
+ // Chromium은 ce=false 래퍼 안의 editable 아울렛(노드뷰)을 만나면
302
+ // 네이티브 select-all이 셀렉션을 collapse시킨다 — keydown에서
303
+ // 가로채 doc 전체 선택을 직접 디스패치한다
304
+ const editor = specEditor();
305
+ const { el, flush, destroy } = await mount({ editor });
306
+ const editable = el.querySelector(".me-editor");
307
+ const ev = new KeyboardEvent("keydown", {
308
+ key: "a",
309
+ metaKey: true,
310
+ bubbles: true,
311
+ cancelable: true,
312
+ });
313
+ editable.dispatchEvent(ev);
314
+ await flush?.();
315
+ expect(ev.defaultPrevented).toBe(true);
316
+ // DOM 복원 후 selectionchange가 리프 좌표로 재동기화한다 —
317
+ // happy-dom은 Selection 구현이 제한적이라 끝점 오프셋이
318
+ // 클램프될 수 있어, 앵커=문서 시작 + 비축소 범위를 본다.
319
+ // DOM 셀렉션 확장 자체는 e2e(감사 — Mod-A)가 검증한다
320
+ const sel = editor.state.selection;
321
+ expect(sel.type).toBe("text");
322
+ if (sel.type === "text") {
323
+ const start = leafPoint(editor.state.doc, {
324
+ path: [],
325
+ offset: 0,
326
+ });
327
+ expect(leafPoint(editor.state.doc, sel.anchor)).toEqual(start);
328
+ expect(pointEq(sel.anchor, sel.focus)).toBe(false);
329
+ }
330
+ destroy();
331
+ });
332
+ it("전체 선택 삭제 후 타이핑은 단일 문단이 된다", async () => {
333
+ // Cmd+A → 타이핑과 같은 경로 — doc 루트 경계 포인트에
334
+ // bare text가 들어가는 회귀 방지
335
+ const editor = specEditor();
336
+ const { el, flush, destroy } = await mount({ editor });
337
+ editor.dispatch(editor.state
338
+ .tr()
339
+ .delete({ anchor: point([], 0), focus: point([], 2) })
340
+ .insertText("Z", point([], 0)));
341
+ await flush?.();
342
+ const blocks = el.querySelectorAll(".me-editor > [data-me-path]");
343
+ expect(blocks.length).toBe(1);
344
+ expect(blocks[0].tagName.toLowerCase()).toBe("p");
345
+ expect(blocks[0].textContent).toBe("Z");
346
+ expect(editor.state.doc.content[0].type).toBe("paragraph");
347
+ destroy();
348
+ });
349
+ it("insertFromDrop — 텍스트 드롭은 트랜잭션으로 삽입된다", async () => {
350
+ const editor = createEditor({
351
+ doc: createDoc([{ type: "paragraph", content: [createText("ab")] }]),
352
+ extensions: starterExtensions(),
353
+ });
354
+ const { el, flush, destroy } = await mount({ editor });
355
+ const doc = el.ownerDocument;
356
+ const editable = el.querySelector(".me-editor");
357
+ const holder = editable.querySelector("[data-me-path='0,0']");
358
+ // 렌더러마다 텍스트 노드 분할이 다르다 — 마지막 텍스트 노드 끝에 캐럿
359
+ const walker = doc.createTreeWalker(holder, 4);
360
+ let t = null;
361
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
362
+ if (n.textContent)
363
+ t = n;
364
+ }
365
+ doc
366
+ .getSelection()
367
+ .setBaseAndExtent(t, t.textContent.length, t, t.textContent.length);
368
+ const ev = new Event("beforeinput", {
369
+ bubbles: true,
370
+ cancelable: true,
371
+ });
372
+ Object.defineProperties(ev, {
373
+ inputType: { value: "insertFromDrop" },
374
+ data: { value: "XY" },
375
+ });
376
+ editable.dispatchEvent(ev);
377
+ await flush?.();
378
+ expect(el.querySelector("p").textContent).toBe("abXY");
379
+ destroy();
380
+ });
381
+ it("historyUndo/historyRedo beforeinput — 우리 히스토리로 돌린다", async () => {
382
+ const editor = createEditor({
383
+ doc: createDoc([{ type: "paragraph", content: [createText("a")] }]),
384
+ extensions: starterExtensions(),
385
+ });
386
+ const { el, flush, destroy } = await mount({ editor });
387
+ const doc = el.ownerDocument;
388
+ const editable = el.querySelector(".me-editor");
389
+ const holder = editable.querySelector("[data-me-path='0,0']");
390
+ // 렌더러마다 텍스트 노드 분할이 다르다 — 마지막 텍스트 노드 끝에 캐럿
391
+ const walker = doc.createTreeWalker(holder, 4);
392
+ let t = null;
393
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
394
+ if (n.textContent)
395
+ t = n;
396
+ }
397
+ doc
398
+ .getSelection()
399
+ .setBaseAndExtent(t, t.textContent.length, t, t.textContent.length);
400
+ const fire = (inputType, data) => {
401
+ const ev = new Event("beforeinput", {
402
+ bubbles: true,
403
+ cancelable: true,
404
+ });
405
+ Object.defineProperties(ev, {
406
+ inputType: { value: inputType },
407
+ data: { value: data },
408
+ });
409
+ editable.dispatchEvent(ev);
410
+ return ev;
411
+ };
412
+ // 먼저 실제 편집 하나 — undo 스택에 쌓이게
413
+ fire("insertText", "b");
414
+ await flush?.();
415
+ expect(el.querySelector("p").textContent).toBe("ab");
416
+ // iOS/Android 실행취소 제스처 → preventDefault + undo
417
+ const u = fire("historyUndo");
418
+ expect(u.defaultPrevented).toBe(true);
419
+ await flush?.();
420
+ expect(el.querySelector("p").textContent).toBe("a");
421
+ fire("historyRedo");
422
+ await flush?.();
423
+ expect(el.querySelector("p").textContent).toBe("ab");
424
+ destroy();
425
+ });
426
+ it("deleteByCut — 잘라내기가 트랜잭션으로 선택을 지운다", async () => {
427
+ // Cmd-X: 방치하면 브라우저가 DOM만 지워 doc과 어긋난다
428
+ const editor = createEditor({
429
+ doc: createDoc([{ type: "paragraph", content: [createText("ab")] }]),
430
+ extensions: starterExtensions(),
431
+ });
432
+ const { el, flush, destroy } = await mount({ editor });
433
+ const doc = el.ownerDocument;
434
+ const editable = el.querySelector(".me-editor");
435
+ const holder = editable.querySelector("[data-me-path='0,0']");
436
+ const walker = doc.createTreeWalker(holder, 4);
437
+ let t = null;
438
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
439
+ if (n.textContent === "ab")
440
+ t = n;
441
+ }
442
+ doc.getSelection().setBaseAndExtent(t, 0, t, 2);
443
+ const ev = new Event("beforeinput", {
444
+ bubbles: true,
445
+ cancelable: true,
446
+ });
447
+ Object.defineProperties(ev, {
448
+ inputType: { value: "deleteByCut" },
449
+ });
450
+ editable.dispatchEvent(ev);
451
+ await flush?.();
452
+ expect(ev.defaultPrevented).toBe(true);
453
+ expect(el.querySelector("p").textContent).toBe("");
454
+ destroy();
455
+ });
456
+ it("deleteWord* — getTargetRanges 범위를 트랜잭션으로 지운다", async () => {
457
+ // macOS Option-Delete: 방치하면 네이티브 단어 삭제가 doc을 우회
458
+ const editor = createEditor({
459
+ doc: createDoc([{ type: "paragraph", content: [createText("foo bar")] }]),
460
+ extensions: starterExtensions(),
461
+ });
462
+ const { el, flush, destroy } = await mount({ editor });
463
+ const doc = el.ownerDocument;
464
+ const editable = el.querySelector(".me-editor");
465
+ const holder = editable.querySelector("[data-me-path='0,0']");
466
+ const walker = doc.createTreeWalker(holder, 4);
467
+ let t = null;
468
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
469
+ if (n.textContent === "foo bar")
470
+ t = n;
471
+ }
472
+ doc.getSelection().setBaseAndExtent(t, 7, t, 7);
473
+ const ev = new Event("beforeinput", {
474
+ bubbles: true,
475
+ cancelable: true,
476
+ });
477
+ Object.defineProperties(ev, {
478
+ inputType: { value: "deleteWordBackward" },
479
+ // happy-dom에는 getTargetRanges가 없으므로 오리타이핑으로
480
+ // 붙인다 — 코드는 startContainer/startOffset만 읽는다
481
+ getTargetRanges: {
482
+ value: () => [
483
+ {
484
+ startContainer: t,
485
+ startOffset: 4,
486
+ endContainer: t,
487
+ endOffset: 7,
488
+ },
489
+ ],
490
+ },
491
+ });
492
+ editable.dispatchEvent(ev);
493
+ await flush?.();
494
+ expect(ev.defaultPrevented).toBe(true);
495
+ expect(el.querySelector("p").textContent).toBe("foo ");
496
+ destroy();
497
+ });
498
+ it("이모지 Backspace — 서로게이트 페어가 통째로 지워진다", async () => {
499
+ // getTargetRanges가 없으면 코드 유닛 하나만 지워 절반 페어가
500
+ // 남는다 — 브라우저가 주는 그래피임 범위를 우선 쓴다
501
+ const editor = createEditor({
502
+ doc: createDoc([{ type: "paragraph", content: [createText("a😀b")] }]),
503
+ extensions: starterExtensions(),
504
+ });
505
+ const { el, flush, destroy } = await mount({ editor });
506
+ const doc = el.ownerDocument;
507
+ const editable = el.querySelector(".me-editor");
508
+ const holder = editable.querySelector("[data-me-path='0,0']");
509
+ const walker = doc.createTreeWalker(holder, 4);
510
+ let t = null;
511
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
512
+ if (n.textContent === "a😀b")
513
+ t = n;
514
+ }
515
+ // 캐럿은 😀 뒤 (a=1 + 서로게이트 페어 2 = 오프셋 3)
516
+ doc.getSelection().setBaseAndExtent(t, 3, t, 3);
517
+ const ev = new Event("beforeinput", {
518
+ bubbles: true,
519
+ cancelable: true,
520
+ });
521
+ Object.defineProperties(ev, {
522
+ inputType: { value: "deleteContentBackward" },
523
+ getTargetRanges: {
524
+ value: () => [
525
+ {
526
+ startContainer: t,
527
+ startOffset: 1,
528
+ endContainer: t,
529
+ endOffset: 3,
530
+ },
531
+ ],
532
+ },
533
+ });
534
+ editable.dispatchEvent(ev);
535
+ await flush?.();
536
+ expect(el.querySelector("p").textContent).toBe("ab");
537
+ destroy();
538
+ });
539
+ it("format*/insertOrderedList — 컨텍스트 메뉴 서식이 우리 커맨드로 매핑된다", async () => {
540
+ const editor = createEditor({
541
+ doc: createDoc([{ type: "paragraph", content: [createText("ab")] }]),
542
+ extensions: starterExtensions(),
543
+ });
544
+ const { el, flush, destroy } = await mount({ editor });
545
+ const doc = el.ownerDocument;
546
+ const editable = el.querySelector(".me-editor");
547
+ const holder = editable.querySelector("[data-me-path='0,0']");
548
+ const walker = doc.createTreeWalker(holder, 4);
549
+ let t = null;
550
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
551
+ if (n.textContent === "ab")
552
+ t = n;
553
+ }
554
+ const fire = (inputType) => {
555
+ const ev = new Event("beforeinput", {
556
+ bubbles: true,
557
+ cancelable: true,
558
+ });
559
+ Object.defineProperties(ev, {
560
+ inputType: { value: inputType },
561
+ });
562
+ editable.dispatchEvent(ev);
563
+ return ev;
564
+ };
565
+ // 선택 후 formatBold → bold 마크
566
+ doc.getSelection().setBaseAndExtent(t, 0, t, 2);
567
+ const b = fire("formatBold");
568
+ expect(b.defaultPrevented).toBe(true);
569
+ await flush?.();
570
+ expect(el.querySelector("strong")?.textContent).toBe("ab");
571
+ // 캐럿에서 insertOrderedList → orderedList 변환
572
+ doc.getSelection().setBaseAndExtent(t, 0, t, 0);
573
+ fire("insertOrderedList");
574
+ await flush?.();
575
+ expect(el.querySelector(".me-editor > ol")).toBeTruthy();
576
+ destroy();
577
+ });
578
+ it("미지원 inputType은 no-op — 네이티브 DOM 변경을 막는다", async () => {
579
+ // 처리하지 않는 타입도 preventDefault해야 브라우저가 DOM만
580
+ // 바꿔 doc과 어긋나는 일이 없다 (insertTranspose, formatFontColor…)
581
+ const editor = createEditor({
582
+ doc: createDoc([{ type: "paragraph", content: [createText("ab")] }]),
583
+ extensions: starterExtensions(),
584
+ });
585
+ const { el, destroy } = await mount({ editor });
586
+ const editable = el.querySelector(".me-editor");
587
+ const ev = new Event("beforeinput", {
588
+ bubbles: true,
589
+ cancelable: true,
590
+ });
591
+ Object.defineProperties(ev, {
592
+ inputType: { value: "formatFontColor" },
593
+ });
594
+ editable.dispatchEvent(ev);
595
+ expect(ev.defaultPrevented).toBe(true);
596
+ expect(el.querySelector("p").textContent).toBe("ab");
597
+ destroy();
598
+ });
599
+ it("'[ ] ' 입력 규칙은 할일 목록을 만든다", async () => {
600
+ const editor = createEditor({
601
+ doc: createDoc([{ type: "paragraph", content: [createText("[ ]")] }]),
602
+ extensions: starterExtensions(),
603
+ });
604
+ const { el, flush, destroy } = await mount({ editor });
605
+ const doc = el.ownerDocument;
606
+ const editable = el.querySelector(".me-editor");
607
+ const holder = editable.querySelector("[data-me-path='0,0']");
608
+ const walker = doc.createTreeWalker(holder, 4);
609
+ let t = null;
610
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
611
+ if (n.textContent === "[ ]")
612
+ t = n;
613
+ }
614
+ doc.getSelection().setBaseAndExtent(t, 3, t, 3);
615
+ const ev = new Event("beforeinput", {
616
+ bubbles: true,
617
+ cancelable: true,
618
+ });
619
+ Object.defineProperties(ev, {
620
+ inputType: { value: "insertText" },
621
+ data: { value: " " },
622
+ });
623
+ editable.dispatchEvent(ev);
624
+ await flush?.();
625
+ expect(el.querySelector("ul.me-tasks > li.me-task > p")).toBeTruthy();
626
+ destroy();
627
+ });
628
+ it("표 셀 안 타이핑과 Tab 셀 이동이 동작한다", async () => {
629
+ const editor = createEditor({
630
+ doc: createDoc([
631
+ {
632
+ type: "table",
633
+ content: [
634
+ {
635
+ type: "tableRow",
636
+ content: [
637
+ {
638
+ type: "tableCell",
639
+ content: [{ type: "paragraph", content: [createText("a")] }],
640
+ },
641
+ {
642
+ type: "tableCell",
643
+ content: [{ type: "paragraph", content: [createText("b")] }],
644
+ },
645
+ ],
646
+ },
647
+ ],
648
+ },
649
+ ]),
650
+ extensions: starterExtensions(),
651
+ });
652
+ const { el, flush, destroy } = await mount({ editor });
653
+ const doc = el.ownerDocument;
654
+ const editable = el.querySelector(".me-editor");
655
+ // 첫 셀의 텍스트 끝에 캐럿 → Tab → 두 번째 셀로
656
+ const holder = editable.querySelector("[data-me-path='0,0,0,0,0']");
657
+ const walker = doc.createTreeWalker(holder, 4);
658
+ let t = null;
659
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
660
+ if (n.textContent === "a")
661
+ t = n;
662
+ }
663
+ doc.getSelection().setBaseAndExtent(t, 1, t, 1);
664
+ editable.dispatchEvent(new KeyboardEvent("keydown", {
665
+ key: "Tab",
666
+ bubbles: true,
667
+ cancelable: true,
668
+ }));
669
+ await flush?.();
670
+ if (editor.state.selection.type === "text") {
671
+ expect(editor.state.selection.anchor.path.slice(0, 3)).toEqual([0, 0, 1]);
672
+ }
673
+ destroy();
674
+ });
675
+ }
676
+ //# sourceMappingURL=editor-input.js.map