@moda-editor/core 0.1.3 → 0.2.0

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 (252) 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 +304 -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 +169 -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 +345 -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/editor.d.ts.map +1 -1
  54. package/dist/editor/editor.js +18 -1
  55. package/dist/editor/editor.js.map +1 -1
  56. package/dist/editor/extension.d.ts +47 -3
  57. package/dist/editor/extension.d.ts.map +1 -1
  58. package/dist/features/ace.d.ts +4 -0
  59. package/dist/features/ace.d.ts.map +1 -1
  60. package/dist/features/ace.js +59 -20
  61. package/dist/features/ace.js.map +1 -1
  62. package/dist/features/block-dnd.d.ts +7 -0
  63. package/dist/features/block-dnd.d.ts.map +1 -0
  64. package/dist/features/block-dnd.js +207 -0
  65. package/dist/features/block-dnd.js.map +1 -0
  66. package/dist/features/code-block.d.ts +9 -84
  67. package/dist/features/code-block.d.ts.map +1 -1
  68. package/dist/features/code-block.js +84 -1034
  69. package/dist/features/code-block.js.map +1 -1
  70. package/dist/features/code-context.d.ts +81 -0
  71. package/dist/features/code-context.d.ts.map +1 -0
  72. package/dist/features/code-context.js +322 -0
  73. package/dist/features/code-context.js.map +1 -0
  74. package/dist/features/code-edit.d.ts +50 -0
  75. package/dist/features/code-edit.d.ts.map +1 -0
  76. package/dist/features/code-edit.js +412 -0
  77. package/dist/features/code-edit.js.map +1 -0
  78. package/dist/features/code-format.d.ts +21 -0
  79. package/dist/features/code-format.d.ts.map +1 -0
  80. package/dist/features/code-format.js +410 -0
  81. package/dist/features/code-format.js.map +1 -0
  82. package/dist/features/doc.d.ts +16 -0
  83. package/dist/features/doc.d.ts.map +1 -1
  84. package/dist/features/doc.js +154 -2
  85. package/dist/features/doc.js.map +1 -1
  86. package/dist/features/heading.d.ts.map +1 -1
  87. package/dist/features/heading.js.map +1 -1
  88. package/dist/features/image.d.ts +3 -1
  89. package/dist/features/image.d.ts.map +1 -1
  90. package/dist/features/image.js +27 -9
  91. package/dist/features/image.js.map +1 -1
  92. package/dist/features/index.d.ts +7 -0
  93. package/dist/features/index.d.ts.map +1 -1
  94. package/dist/features/index.js +16 -0
  95. package/dist/features/index.js.map +1 -1
  96. package/dist/features/inline-style.d.ts.map +1 -1
  97. package/dist/features/inline-style.js +4 -1
  98. package/dist/features/inline-style.js.map +1 -1
  99. package/dist/features/link.d.ts.map +1 -1
  100. package/dist/features/link.js +1 -1
  101. package/dist/features/link.js.map +1 -1
  102. package/dist/features/markdown.d.ts +15 -0
  103. package/dist/features/markdown.d.ts.map +1 -0
  104. package/dist/features/markdown.js +80 -0
  105. package/dist/features/markdown.js.map +1 -0
  106. package/dist/features/marks.d.ts.map +1 -1
  107. package/dist/features/marks.js +49 -4
  108. package/dist/features/marks.js.map +1 -1
  109. package/dist/features/mention.d.ts +30 -0
  110. package/dist/features/mention.d.ts.map +1 -0
  111. package/dist/features/mention.js +89 -0
  112. package/dist/features/mention.js.map +1 -0
  113. package/dist/features/script.d.ts.map +1 -1
  114. package/dist/features/script.js.map +1 -1
  115. package/dist/features/search.d.ts +76 -0
  116. package/dist/features/search.d.ts.map +1 -0
  117. package/dist/features/search.js +281 -0
  118. package/dist/features/search.js.map +1 -0
  119. package/dist/features/shared.d.ts +69 -0
  120. package/dist/features/shared.d.ts.map +1 -0
  121. package/dist/features/shared.js +272 -0
  122. package/dist/features/shared.js.map +1 -0
  123. package/dist/features/table.d.ts.map +1 -1
  124. package/dist/features/table.js +17 -3
  125. package/dist/features/table.js.map +1 -1
  126. package/dist/features/testkit.d.ts.map +1 -1
  127. package/dist/features/testkit.js +1 -1
  128. package/dist/features/testkit.js.map +1 -1
  129. package/dist/history.d.ts.map +1 -1
  130. package/dist/history.js +65 -20
  131. package/dist/history.js.map +1 -1
  132. package/dist/index.d.ts +21 -7
  133. package/dist/index.d.ts.map +1 -1
  134. package/dist/index.js +18 -6
  135. package/dist/index.js.map +1 -1
  136. package/dist/model/schema.d.ts.map +1 -1
  137. package/dist/model/schema.js +2 -1
  138. package/dist/model/schema.js.map +1 -1
  139. package/dist/parse/html.d.ts.map +1 -1
  140. package/dist/parse/html.js +38 -6
  141. package/dist/parse/html.js.map +1 -1
  142. package/dist/parse/sanitize.d.ts.map +1 -1
  143. package/dist/parse/sanitize.js +4 -1
  144. package/dist/parse/sanitize.js.map +1 -1
  145. package/dist/presets/highlight.d.ts.map +1 -1
  146. package/dist/presets/highlight.js +1 -4
  147. package/dist/presets/highlight.js.map +1 -1
  148. package/dist/presets/markdown-parse.d.ts +13 -0
  149. package/dist/presets/markdown-parse.d.ts.map +1 -0
  150. package/dist/presets/markdown-parse.js +398 -0
  151. package/dist/presets/markdown-parse.js.map +1 -0
  152. package/dist/presets/markdown.d.ts +46 -0
  153. package/dist/presets/markdown.d.ts.map +1 -0
  154. package/dist/presets/markdown.js +196 -0
  155. package/dist/presets/markdown.js.map +1 -0
  156. package/dist/state/apply.d.ts.map +1 -1
  157. package/dist/state/apply.js +25 -13
  158. package/dist/state/apply.js.map +1 -1
  159. package/dist/state/delete-ops.d.ts +34 -0
  160. package/dist/state/delete-ops.d.ts.map +1 -0
  161. package/dist/state/delete-ops.js +204 -0
  162. package/dist/state/delete-ops.js.map +1 -0
  163. package/dist/state/input-rules.d.ts +30 -0
  164. package/dist/state/input-rules.d.ts.map +1 -0
  165. package/dist/state/input-rules.js +70 -0
  166. package/dist/state/input-rules.js.map +1 -0
  167. package/dist/state/insert-ops.d.ts +54 -0
  168. package/dist/state/insert-ops.d.ts.map +1 -0
  169. package/dist/state/insert-ops.js +186 -0
  170. package/dist/state/insert-ops.js.map +1 -0
  171. package/dist/state/list-ops.d.ts +46 -0
  172. package/dist/state/list-ops.d.ts.map +1 -0
  173. package/dist/state/list-ops.js +231 -0
  174. package/dist/state/list-ops.js.map +1 -0
  175. package/dist/state/navigate.d.ts.map +1 -1
  176. package/dist/state/navigate.js +3 -1
  177. package/dist/state/navigate.js.map +1 -1
  178. package/dist/state/resolve-insert.d.ts +55 -0
  179. package/dist/state/resolve-insert.d.ts.map +1 -0
  180. package/dist/state/resolve-insert.js +118 -0
  181. package/dist/state/resolve-insert.js.map +1 -0
  182. package/dist/state/slice-ops.d.ts +46 -0
  183. package/dist/state/slice-ops.d.ts.map +1 -0
  184. package/dist/state/slice-ops.js +150 -0
  185. package/dist/state/slice-ops.js.map +1 -0
  186. package/dist/state/state.d.ts +6 -1
  187. package/dist/state/state.d.ts.map +1 -1
  188. package/dist/state/state.js +10 -0
  189. package/dist/state/state.js.map +1 -1
  190. package/dist/state/transaction.d.ts +43 -49
  191. package/dist/state/transaction.d.ts.map +1 -1
  192. package/dist/state/transaction.js +62 -858
  193. package/dist/state/transaction.js.map +1 -1
  194. package/dist/styles.css +401 -213
  195. package/dist/theme.d.ts +29 -0
  196. package/dist/theme.d.ts.map +1 -0
  197. package/dist/theme.js +347 -0
  198. package/dist/theme.js.map +1 -0
  199. package/dist/view/adapter.d.ts +84 -0
  200. package/dist/view/adapter.d.ts.map +1 -0
  201. package/dist/view/adapter.js +76 -0
  202. package/dist/view/adapter.js.map +1 -0
  203. package/dist/view/caret.d.ts +25 -0
  204. package/dist/view/caret.d.ts.map +1 -0
  205. package/dist/view/caret.js +43 -0
  206. package/dist/view/caret.js.map +1 -0
  207. package/dist/view/decoration.d.ts +9 -0
  208. package/dist/view/decoration.d.ts.map +1 -0
  209. package/dist/view/decoration.js +214 -0
  210. package/dist/view/decoration.js.map +1 -0
  211. package/dist/view/dirty-keys.d.ts +18 -0
  212. package/dist/view/dirty-keys.d.ts.map +1 -0
  213. package/dist/view/dirty-keys.js +31 -0
  214. package/dist/view/dirty-keys.js.map +1 -0
  215. package/dist/view/dom.d.ts.map +1 -1
  216. package/dist/view/dom.js +7 -5
  217. package/dist/view/dom.js.map +1 -1
  218. package/dist/view/hook-ctx.d.ts +32 -0
  219. package/dist/view/hook-ctx.d.ts.map +1 -0
  220. package/dist/view/hook-ctx.js +64 -0
  221. package/dist/view/hook-ctx.js.map +1 -0
  222. package/dist/view/ime.d.ts +72 -0
  223. package/dist/view/ime.d.ts.map +1 -0
  224. package/dist/view/ime.js +185 -0
  225. package/dist/view/ime.js.map +1 -0
  226. package/dist/view/input.d.ts +12 -12
  227. package/dist/view/input.d.ts.map +1 -1
  228. package/dist/view/input.js +156 -535
  229. package/dist/view/input.js.map +1 -1
  230. package/dist/view/keys.d.ts +25 -0
  231. package/dist/view/keys.d.ts.map +1 -0
  232. package/dist/view/keys.js +106 -0
  233. package/dist/view/keys.js.map +1 -0
  234. package/dist/view/line-nav.d.ts +13 -0
  235. package/dist/view/line-nav.d.ts.map +1 -0
  236. package/dist/view/line-nav.js +114 -0
  237. package/dist/view/line-nav.js.map +1 -0
  238. package/dist/view/mount.d.ts.map +1 -1
  239. package/dist/view/mount.js +43 -39
  240. package/dist/view/mount.js.map +1 -1
  241. package/dist/view/node-selection.d.ts +33 -0
  242. package/dist/view/node-selection.d.ts.map +1 -0
  243. package/dist/view/node-selection.js +107 -0
  244. package/dist/view/node-selection.js.map +1 -0
  245. package/dist/view/render.d.ts +4 -1
  246. package/dist/view/render.d.ts.map +1 -1
  247. package/dist/view/render.js +22 -3
  248. package/dist/view/render.js.map +1 -1
  249. package/dist/view/serialize.d.ts.map +1 -1
  250. package/dist/view/serialize.js +14 -5
  251. package/dist/view/serialize.js.map +1 -1
  252. package/package.json +1 -1
@@ -1,132 +1,21 @@
1
1
  import { nodeAt } from "../model/traverse.js";
2
2
  import { parseHTML } from "../parse/html.js";
3
3
  import { pointBefore, pointAfter } from "../state/navigate.js";
4
- import { nodeSelection, normalizeRange, point, pointEq, textSelection, } from "../state/selection.js";
5
- import { applyDomSelection, domSelection, domToPoint, PATH_ATTR, pointToDOM, } from "./dom.js";
6
- import { blockText, blockTextOffset, blockTextPoint, toggleMark, } from "../commands.js";
7
- /** format* inputType → 마크 타입 (스펙의 inputType 이름 ↔ 스키마 마크) */
8
- const FORMAT_MARKS = {
9
- formatBold: "bold",
10
- formatItalic: "italic",
11
- formatUnderline: "underline",
12
- formatStrikeThrough: "strike",
13
- formatSuperscript: "superscript",
14
- formatSubscript: "subscript",
15
- };
16
- /** drop 좌표 → doc Point — caretPositionFromPoint / Range 폴백 */
17
- function caretPointFromXY(doc, root, x, y) {
18
- const d = doc;
19
- const pos = doc.caretPositionFromPoint?.(x, y);
20
- if (pos)
21
- return domToPoint(root, pos.offsetNode, pos.offset);
22
- const r = d.caretRangeFromPoint?.(x, y);
23
- return r ? domToPoint(root, r.startContainer, r.startOffset) : null;
24
- }
25
- /**
26
- * 텍스트 범위가 한 블록 안에 있으면 입력 훅용 컨텍스트 — 블록을
27
- * 걸치는 범위는 훅이 받지 않는다 (BlockTextCtx 계약).
28
- */
29
- function blockCtx(editor, r, tr) {
30
- const blockPath = r.anchor.path.slice(0, -1);
31
- if (r.focus.path.length !== blockPath.length + 1 ||
32
- !r.focus.path.slice(0, -1).every((v, i) => v === blockPath[i])) {
33
- return null;
34
- }
35
- const block = nodeAt(editor.state.doc, blockPath);
36
- if (!block)
37
- return null;
38
- return {
39
- state: editor.state,
40
- tr,
41
- dispatch: () => editor.dispatch(tr),
42
- block,
43
- blockPath,
44
- text: blockText(block),
45
- anchor: r.anchor,
46
- focus: r.focus,
47
- charPoint: (off) => blockTextPoint(block, blockPath, off),
48
- offsetOf: (p) => blockTextOffset(block, p),
49
- };
50
- }
51
- /**
52
- * 어댑터 공용 dirty-key 헬퍼 — binding.dirtyPaths를 소비해 블록
53
- * 렌더 키를 안정적으로 버전업한다. 일반 블록은 path 그대로,
54
- * 외부 DOM 변경이 있었던 블록은 path@N으로 키가 바뀌어
55
- * 프레임워크가 내부 diff 대신 리마운트를 택한다.
56
- */
57
- export class DirtyKeyMap {
58
- constructor() {
59
- Object.defineProperty(this, "versions", {
60
- enumerable: true,
61
- configurable: true,
62
- writable: true,
63
- value: new Map()
64
- });
65
- }
66
- /** rn.path.join(",")와 binding.dirtyPaths를 넣으면 렌더 키를 반환 */
67
- key(pathKey, dirty) {
68
- if (dirty?.delete(pathKey)) {
69
- this.versions.set(pathKey, (this.versions.get(pathKey) ?? 0) + 1);
70
- }
71
- const v = this.versions.get(pathKey);
72
- return v ? `${pathKey}@${v}` : pathKey;
73
- }
74
- }
75
- /** KeyboardEvent → "Mod-b"/"Shift-Enter" 형태 키 문자열 (PM 관례) */
76
- const LINE_NAV_KEYS = new Set([
77
- "Home",
78
- "End",
79
- "Shift-Home",
80
- "Shift-End",
81
- "Mod-ArrowLeft",
82
- "Mod-ArrowRight",
83
- "Mod-Shift-ArrowLeft",
84
- "Mod-Shift-ArrowRight",
85
- "Mod-Home",
86
- "Mod-End",
87
- "Mod-Shift-Home",
88
- "Mod-Shift-End",
89
- ]);
90
- function normalizeKey(e) {
91
- const mods = [];
92
- if (e.ctrlKey || e.metaKey)
93
- mods.push("Mod");
94
- if (e.altKey)
95
- mods.push("Alt");
96
- if (e.shiftKey)
97
- mods.push("Shift");
98
- const name = e.key.length === 1 ? e.key.toLowerCase() : e.key;
99
- return [...mods, name].join("-");
100
- }
4
+ import { normalizeRange, point, pointEq, textSelection, } from "../state/selection.js";
5
+ import { caretPointFromXY, isLeafPoint } from "./caret.js";
6
+ import { applyDomSelection, domSelection, domToPoint, PATH_ATTR, } from "./dom.js";
7
+ import { blockCtx, dispatchFiles, rangeCtx } from "./hook-ctx.js";
8
+ import { createImeSession } from "./ime.js";
9
+ import { lineNav } from "./line-nav.js";
10
+ import { nodeSelectionBeforeInput, nodeSelectionKeydown, trySelectAtom, } from "./node-selection.js";
11
+ import { dispatchFormatInput, LINE_NAV_KEYS, normalizeKey } from "./keys.js";
101
12
  export function bindEditorInput(editor, el) {
102
- let composing = false;
103
- let compStart = null;
104
- let compTimer = null;
105
- // compositionend가 왔고 흡수만 남은 상태 — 이 창에 도착하는 구조
106
- // 입력(Enter/Backspace)은 커밋이 끝난 뒤의 것이라 브라우저에 맡기면
107
- // 네이티브 split/delete가 doc 밖 DOM을 만든다
108
- let pendingAbsorb = false;
109
- // 실제 IME는 compositionend 뒤 같은 텍스트의 최종 커밋 beforeinput을
110
- // 한 번 더 보낸다(Safari 확정, Chromium도 비동기 커밋 경로가 있다) —
111
- // 타이머보다 늦게 오면 에코를 구별하기 위해 종료 시각을 기록한다
112
- let compEndedAt = 0;
113
13
  const dirtyPaths = new Set();
114
- /** p가 속한 최상위 블록 경로 — 프레임워크 리마운트 대상 */
115
- const topBlockKey = (p) => p?.path[0] == null ? null : String(p.path[0]);
116
- /**
117
- * DOM 캐럿 바로 앞 텍스트가 data로 끝나는가 — IME 커밋 에코 감지.
118
- * 조합 중 브라우저가 쓴 글자가 DOM에 남아있으면 뒤에 도착하는
119
- * 커밋 beforeinput은 중복이다.
120
- */
121
- const domEndsWith = (focus, data) => {
122
- const holder = focus.path.length
123
- ? el.querySelector(`[${PATH_ATTR}="${focus.path.join(",")}"]`)
124
- : null;
125
- if (!holder || holder.querySelector(`[${PATH_ATTR}]`))
126
- return false;
127
- return (holder.textContent ?? "").slice(0, focus.offset).endsWith(data);
128
- };
129
14
  const readDomSel = () => domSelection(el);
15
+ // IME 조합 세션 — composing/compStart/타이머/에코 판별은 전부 세션
16
+ // 내부다. 이 파일은 DOM 이벤트를 세션 메서드로 변환하고 판정
17
+ // 결과(ImeGate)에 따라 preventDefault/무시만 수행한다
18
+ const ime = createImeSession({ editor, el, dirtyPaths, readDomSel });
130
19
  /**
131
20
  * 기능 DOM 오버레이 갱신 — 활성 줄 표시·괄호 하이라이트처럼
132
21
  * 렌더 트리가 아니라 DOM을 직접 마킹하는 것들. 각 기능이
@@ -142,8 +31,10 @@ export function bindEditorInput(editor, el) {
142
31
  el.querySelectorAll(".is-selected").forEach((n) => n.classList.remove("is-selected"));
143
32
  const sel = editor.state.selection;
144
33
  if (sel.type === "node") {
145
- el.querySelector(`[${PATH_ATTR}="${sel.path.join(",")}"]`)
146
- ?.classList.add("is-selected");
34
+ // data-me-path가 안쪽 아울렛(.me-node-content)에 달린 노드 뷰는
35
+ // 래퍼(.me-node-view)까지 올라가 컴포넌트 전체에 링을 둘러야 한다
36
+ const hit = el.querySelector(`[${PATH_ATTR}="${sel.path.join(",")}"]`);
37
+ (hit?.closest(".me-node-view") ?? hit)?.classList.add("is-selected");
147
38
  }
148
39
  };
149
40
  /**
@@ -157,10 +48,42 @@ export function bindEditorInput(editor, el) {
157
48
  applyDomSelection(el, anchor, focus);
158
49
  lastApplied = { anchor, focus };
159
50
  };
51
+ // 포커스가 에디터 밖에 있는 동안 건너뛴 DOM 셀렉션 반영 —
52
+ // focusin에서 한 번에 동기화한다
53
+ let pendingSel = false;
160
54
  const restoreSelection = () => {
161
55
  const sel = editor.state.selection;
162
- // 노드 셀렉션은 DOM 캐럿이 없다 — 오버레이만 갱신하고 끝낸다
56
+ // 포커스가 에디터 밖의 다른 요소(찾기 바·팝오버 입력)에 잡혀
57
+ // 있으면 DOM 셀렉션을 건드리지 않는다 — Blink는 contenteditable
58
+ // 안에 셀렉션을 쓰면 그 요소를 focus하므로, 매 커밋마다 입력
59
+ // 포커스를 빼앗긴다 (PM의 hasFocus 관례). 아무것도 포커스하지
60
+ // 않은 상태(body)는 기존처럼 반영한다 — 어차피 뺏을 포커스가
61
+ // 없고, DOM 셀렉션을 읽는 오버레이(ace 활성 라인)가 동작해야 한다
62
+ const ae = el.ownerDocument.activeElement;
63
+ if (ae && ae !== el.ownerDocument.body && !el.contains(ae)) {
64
+ pendingSel = true;
65
+ paintOverlays();
66
+ return;
67
+ }
68
+ pendingSel = false;
163
69
  if (sel.type !== "text") {
70
+ // 노드 셀렉션 — DOM 캐럿을 노드 요소의 경계로 고정한다 (PM 관례).
71
+ // 고정하지 않으면 팝 닫힘·포커스 복귀 등으로 브라우저가 캐럿을
72
+ // 옮길 때 selectionchange가 그 위치를 읽어 노드 셀렉션을
73
+ // 되감는다 — 경계에 고정하면 이후 이벤트는 같은 위치를 읽는다
74
+ const hit = el.querySelector(`[${PATH_ATTR}="${sel.path.join(",")}"]`);
75
+ const host = hit?.closest(".me-node-view") ?? hit;
76
+ const pi = sel.path[sel.path.length - 1];
77
+ if (host?.parentNode && pi != null) {
78
+ const idx = Array.prototype.indexOf.call(host.parentNode.childNodes, host);
79
+ const ds = el.ownerDocument.getSelection();
80
+ ds?.setBaseAndExtent(host.parentNode, idx, host.parentNode, idx);
81
+ // 실제로 읽히는 Point를 기록 — childNodes 인덱스와 domToPoint의
82
+ // 요소 인덱스가 어긋나도 이후 selectionchange는 같은 값을 읽는다
83
+ const back = readDomSel();
84
+ const bp = point(sel.path.slice(0, -1), pi);
85
+ lastApplied = back ?? { anchor: bp, focus: bp };
86
+ }
164
87
  paintOverlays();
165
88
  return;
166
89
  }
@@ -180,20 +103,16 @@ export function bindEditorInput(editor, el) {
180
103
  // setSel이 먹지 않는 역효과 방지)
181
104
  // - 리프 포인트 — 렌더가 노드를 교체하면 Chrome이 캐럿을 요소
182
105
  // 경계로 재앵커하는데 그건 사용자 이동이 아니라 폐기 대상이다
183
- const isLeaf = (p) => nodeAt(editor.state.doc, p.path)?.type === "text";
184
106
  if (cur &&
185
107
  lastApplied &&
186
- isLeaf(cur.anchor) &&
187
- isLeaf(cur.focus) &&
108
+ isLeafPoint(editor.state.doc, cur.anchor) &&
109
+ isLeafPoint(editor.state.doc, cur.focus) &&
188
110
  pointEq(sel.anchor, lastApplied.anchor) &&
189
111
  pointEq(sel.focus, lastApplied.focus) &&
190
112
  (!pointEq(cur.anchor, lastApplied.anchor) ||
191
113
  !pointEq(cur.focus, lastApplied.focus))) {
192
114
  lastApplied = cur;
193
- editor.dispatch(editor.state
194
- .tr()
195
- .setSelection({ type: "text", ...cur })
196
- .setStoredMarks([]));
115
+ editor.dispatch(editor.state.tr().setSelection({ type: "text", ...cur }));
197
116
  paintOverlays();
198
117
  return;
199
118
  }
@@ -204,17 +123,19 @@ export function bindEditorInput(editor, el) {
204
123
  const s = readDomSel();
205
124
  if (!s)
206
125
  return;
126
+ // 흡수한 DOM 위치를 확인된 값으로 기록 — 이 위치에 대한 지연
127
+ // selectionchange(클릭 직후 키 입력 등)가 도착해도 "사용자가
128
+ // 새로 움직인 셀렉션"으로 오인하지 않게 한다 (노드 셀렉션 붕괴 방지)
129
+ lastApplied = s;
207
130
  const cur = editor.state.selection;
208
131
  if (cur.type === "text" &&
209
132
  pointEq(cur.anchor, s.anchor) &&
210
133
  pointEq(cur.focus, s.focus)) {
211
134
  return;
212
135
  }
213
- // 커서 이동은 대기 중인 storedMarks를 지운다 (PM 관례 — []가 클리어)
214
- editor.dispatch(editor.state
215
- .tr()
216
- .setSelection({ type: "text", ...s })
217
- .setStoredMarks([]));
136
+ // 대기 서식(storedMarks)은 커서 이동에도 유지한다 — GDocs/Word
137
+ // 관례: 토글로 끌 때까지 다음 타이핑에 계속 적용된다
138
+ editor.dispatch(editor.state.tr().setSelection({ type: "text", ...s }));
218
139
  };
219
140
  const onBeforeInput = (e) => {
220
141
  if (!editor.editable || e.isComposing)
@@ -223,29 +144,14 @@ export function bindEditorInput(editor, el) {
223
144
  // 흡수된 텍스트를 포함해 정확하고, 플러시의 리렌더가 DOM
224
145
  // 셀렉션을 doc(조합 시작점)으로 되돌리기 전의 값이다
225
146
  const domSel = readDomSel();
226
- if (composing) {
227
- // 조합 종료 직후(흡수 대기) 도착한 구조 입력만 통과 — 커밋-Enter의
228
- // insertParagraph가 대표적이다. 텍스트 계열은 계속 브라우저에
229
- // 맡겨 DOM이 바뀌게 두고 타이머의 흡수가 잡는다
230
- const structural = e.inputType === "insertParagraph" ||
231
- e.inputType === "insertLineBreak" ||
232
- e.inputType === "insertFromPaste" ||
233
- e.inputType === "insertFromDrop" ||
234
- e.inputType.startsWith("delete");
235
- if (!pendingAbsorb || !structural) {
236
- // 커밋 에코 — DOM에 이미 같은 텍스트가 쓰여 있으면(조합 중 브라우저가
237
- // 넣은 상태) 통과시키면 브라우저가 한 번 더 써서 "세세"가 된다.
238
- // DOM에 없으면(Safari 최종 커밋) 살려둬야 글자가 사라지지 않는다
239
- if ((e.inputType === "insertText" ||
240
- e.inputType === "insertCompositionText") &&
241
- e.data &&
242
- domSel &&
243
- domEndsWith(domSel.focus, e.data)) {
244
- e.preventDefault();
245
- }
246
- return;
247
- }
248
- flushComposition();
147
+ // 조합 세션 게이트 — swallow(조합 중 무시)/prevent(커밋 에코 차단)/
148
+ // proceed(정상 처리 — 흡수 대기 중의 구조 입력은 플러시 후 통과)
149
+ const gated = ime.gate(e, domSel);
150
+ if (gated === "swallow")
151
+ return;
152
+ if (gated === "prevent") {
153
+ e.preventDefault();
154
+ return;
249
155
  }
250
156
  const ds = editor.state.selection;
251
157
  // 노드 셀렉션 — DOM 캐럿은 클릭 전 위치에 멈춰 있어 신뢰 불가.
@@ -253,28 +159,7 @@ export function bindEditorInput(editor, el) {
253
159
  // 경계에서 일어나는 것으로 처리한다 (노드는 유지)
254
160
  if (ds.type === "node") {
255
161
  e.preventDefault();
256
- const parent = ds.path.slice(0, -1);
257
- const idx = ds.path[ds.path.length - 1] ?? 0;
258
- const tr = editor.state.tr();
259
- if (e.inputType.startsWith("delete")) {
260
- tr.delete({
261
- anchor: point(parent, idx),
262
- focus: point(parent, idx + 1),
263
- });
264
- }
265
- else if (e.inputType === "insertParagraph" ||
266
- e.inputType === "insertLineBreak") {
267
- tr.split(point(parent, idx + 1));
268
- }
269
- else if ((e.inputType === "insertText" ||
270
- e.inputType === "insertCompositionText") &&
271
- e.data) {
272
- tr.insertText(e.data, point(parent, idx + 1));
273
- }
274
- else {
275
- return;
276
- }
277
- editor.dispatch(tr);
162
+ nodeSelectionBeforeInput(editor, e);
278
163
  return;
279
164
  }
280
165
  // DOM 셀렉션이 무효하면(리렌더로 분리된 노드 위 등) doc 셀렉션으로
@@ -290,12 +175,8 @@ export function bindEditorInput(editor, el) {
290
175
  return;
291
176
  // 커밋 에코가 흡수 타이머보다 늦게 도착한 경우 — DOM과 doc에
292
177
  // 이미 같은 글자가 있으므로 다시 넣으면 "세세"가 된다
293
- if (compEndedAt > 0 &&
294
- performance.now() - compEndedAt < 300 &&
295
- domSel &&
296
- domEndsWith(domSel.focus, data)) {
178
+ if (ime.isLateEcho(data, domSel))
297
179
  return;
298
- }
299
180
  // 범위 삭제 뒤엔 삭제 전 좌표가 무효 — 정규화한 앞점 기준으로 삽입
300
181
  const r = normalizeRange(sel);
301
182
  // 입력 훅 — 기능이 글자 가로채기(코드블록 자동 쌍·타입오버 등)를
@@ -417,8 +298,12 @@ export function bindEditorInput(editor, el) {
417
298
  // 끝점이 요소 레벨 포인트(컨테이너 자식 경계, 블록 머지 시 Chrome이
418
299
  // (P,0) 같은 위치를 줌)면 tr.delete가 해석하지 못한다 — 리프
419
300
  // 포인트를 주는 pointBefore/After 폴백으로 돌린다
420
- const leaf = (p) => !!p && nodeAt(editor.state.doc, p.path)?.type === "text";
421
- if (ta && tf && !pointEq(ta, tf) && onCaret && leaf(ta) && leaf(tf)) {
301
+ if (ta &&
302
+ tf &&
303
+ !pointEq(ta, tf) &&
304
+ onCaret &&
305
+ isLeafPoint(editor.state.doc, ta) &&
306
+ isLeafPoint(editor.state.doc, tf)) {
422
307
  tr.delete(normalizeRange({ anchor: ta, focus: tf }));
423
308
  tr.setMeta("history.group", "delete");
424
309
  editor.dispatch(tr);
@@ -444,13 +329,7 @@ export function bindEditorInput(editor, el) {
444
329
  const r = normalizeRange(sel);
445
330
  // 기능 훅 — 링크 URL 감싸기·코드블록 순수 텍스트 등.
446
331
  // 하나라도 소비하면 기본 경로를 건너뛴다
447
- const ctx = {
448
- state: editor.state,
449
- tr,
450
- dispatch: () => editor.dispatch(tr),
451
- anchor: r.anchor,
452
- focus: r.focus,
453
- };
332
+ const ctx = rangeCtx(editor, r, tr);
454
333
  const data = { html, text };
455
334
  let consumed = false;
456
335
  for (const h of editor.inputHooks) {
@@ -487,8 +366,7 @@ export function bindEditorInput(editor, el) {
487
366
  let r = normalizeRange(sel);
488
367
  if (e.inputType === "insertReplacementText") {
489
368
  const tr0 = e.getTargetRanges?.()[0];
490
- const a = tr0 &&
491
- domToPoint(el, tr0.startContainer, tr0.startOffset);
369
+ const a = tr0 && domToPoint(el, tr0.startContainer, tr0.startOffset);
492
370
  const f = tr0 && domToPoint(el, tr0.endContainer, tr0.endOffset);
493
371
  if (a && f)
494
372
  r = normalizeRange({ anchor: a, focus: f });
@@ -501,14 +379,6 @@ export function bindEditorInput(editor, el) {
501
379
  editor.dispatch(tr);
502
380
  break;
503
381
  }
504
- case "historyUndo":
505
- case "historyRedo": {
506
- // iOS/Android의 OS 실행취소·재실행 제스처 — 네이티브가 DOM을
507
- // 되돌리면 doc과 어긋나니 우리 히스토리로 돌린다
508
- e.preventDefault();
509
- editor.commands[e.inputType === "historyUndo" ? "undo" : "redo"]?.();
510
- break;
511
- }
512
382
  // 단어/줄 단위·드래그·잘라내기 삭제 — macOS Option-Delete는
513
383
  // deleteWord*, Ctrl-K는 deleteSoftLine*, Cmd-X는 deleteByCut으로
514
384
  // 온다. getTargetRanges가 브라우저가 계산한 정확한 범위를 주므로
@@ -533,12 +403,11 @@ export function bindEditorInput(editor, el) {
533
403
  // 레벨 포인트면 tr.delete가 해석 못하므로 리프만 받는다
534
404
  const delBack = e.inputType.endsWith("Backward");
535
405
  const delFwd = e.inputType.endsWith("Forward");
536
- const leafP = (p) => !!p && nodeAt(editor.state.doc, p.path)?.type === "text";
537
406
  const trusted = a &&
538
407
  f &&
539
408
  !pointEq(a, f) &&
540
- leafP(a) &&
541
- leafP(f) &&
409
+ isLeafPoint(editor.state.doc, a) &&
410
+ isLeafPoint(editor.state.doc, f) &&
542
411
  (!delBack || pointEq(f, sel.focus)) &&
543
412
  (!delFwd || pointEq(a, sel.focus));
544
413
  const r = trusted
@@ -550,59 +419,7 @@ export function bindEditorInput(editor, el) {
550
419
  }
551
420
  // 모바일·컨텍스트 메뉴·리더의 서식 명령 — 네이티브 execCommand가
552
421
  // DOM을 직접 바꾸면 어긋나므로 우리 커맨드로 매핑한다
553
- case "formatBold":
554
- case "formatItalic":
555
- case "formatUnderline":
556
- case "formatStrikeThrough":
557
- case "formatSuperscript":
558
- case "formatSubscript": {
559
- e.preventDefault();
560
- const mark = FORMAT_MARKS[e.inputType];
561
- if (editor.state.schema.markSpec(mark)) {
562
- toggleMark(mark)({
563
- state: editor.state,
564
- tr,
565
- dispatch: () => editor.dispatch(tr),
566
- });
567
- }
568
- break;
569
- }
570
- case "formatRemove": {
571
- e.preventDefault();
572
- editor.commands.clearFormat?.();
573
- break;
574
- }
575
- case "formatJustifyLeft":
576
- case "formatJustifyCenter":
577
- case "formatJustifyRight": {
578
- e.preventDefault();
579
- const align = {
580
- formatJustifyLeft: "left",
581
- formatJustifyCenter: "center",
582
- formatJustifyRight: "right",
583
- }[e.inputType];
584
- editor.commands.setAlign?.(align);
585
- break;
586
- }
587
- case "formatIndent":
588
- case "formatOutdent": {
589
- e.preventDefault();
590
- editor.commands[e.inputType === "formatIndent" ? "sinkListItem" : "liftListItem"]?.();
591
- break;
592
- }
593
- case "insertUnorderedList":
594
- case "insertOrderedList": {
595
- e.preventDefault();
596
- editor.commands[e.inputType === "insertOrderedList"
597
- ? "toggleOrderedList"
598
- : "toggleBulletList"]?.();
599
- break;
600
- }
601
- case "insertHorizontalRule": {
602
- e.preventDefault();
603
- editor.commands.insertHorizontalRule?.();
604
- break;
605
- }
422
+ // (매핑 테이블은 keys.ts의 dispatchFormatInput)
606
423
  case "insertFromYank":
607
424
  case "insertFromPasteAsQuotation": {
608
425
  // macOS 킬링 붙여넣기(Ctrl-Y)·인용 붙여넣기 — 있으면 순수
@@ -618,10 +435,15 @@ export function bindEditorInput(editor, el) {
618
435
  editor.dispatch(tr);
619
436
  break;
620
437
  }
621
- // 나머지 타입(insertTranspose, formatFontColor 등)은 우리가
622
- // 처리하지 않는다 — 그대로 두면 브라우저가 DOM만 바꿔 doc과
623
- // 영구히 어긋나므로 기본 동작을 막아 no-op으로 둔다
624
438
  default:
439
+ // format*/history*/insert*List 등 — 커맨드 디스패치 테이블에 위임
440
+ if (dispatchFormatInput(editor, e.inputType)) {
441
+ e.preventDefault();
442
+ break;
443
+ }
444
+ // 나머지 타입(insertTranspose, formatFontColor 등)은 우리가
445
+ // 처리하지 않는다 — 그대로 두면 브라우저가 DOM만 바꿔 doc과
446
+ // 영구히 어긋나므로 기본 동작을 막아 no-op으로 둔다
625
447
  e.preventDefault();
626
448
  break;
627
449
  }
@@ -631,7 +453,7 @@ export function bindEditorInput(editor, el) {
631
453
  * false 반환은 다음 바인딩으로 폴스루 (PM keymap 관례).
632
454
  */
633
455
  const onKeydown = (e) => {
634
- if (composing || e.isComposing || !editor.editable)
456
+ if (ime.active || e.isComposing || !editor.editable)
635
457
  return;
636
458
  const key = normalizeKey(e);
637
459
  // Mod-A 전체 선택은 직접 처리 — 중첩 편집 호스트(.me-node-view 안의
@@ -639,49 +461,25 @@ export function bindEditorInput(editor, el) {
639
461
  // 셀렉션을 펼치지 못하고 문서 시작으로 collapse한다
640
462
  if (key === "Mod-a") {
641
463
  e.preventDefault();
642
- editor.dispatch(editor.state.tr().setSelection(textSelection({ path: [], offset: 0 }, { path: [], offset: editor.state.doc.content?.length ?? 0 })));
464
+ editor.dispatch(editor.state
465
+ .tr()
466
+ .setSelection(textSelection({ path: [], offset: 0 }, { path: [], offset: editor.state.doc.content?.length ?? 0 })));
643
467
  return;
644
468
  }
469
+ // 클릭 직후 키 입력처럼 selectionchange가 아직 도착하지 않은 창에는
470
+ // doc 셀렉션이 stale하다 — PM domObserver.flush 관례로 키 처리 전에
471
+ // DOM 셀렉션을 먼저 흡수한다. 노드 셀렉션 중엔 DOM 캐럿이 무의미한
472
+ // stale 위치라 덮어쓰지 않는다
473
+ if (editor.state.selection.type !== "node")
474
+ syncDocSelection();
645
475
  // 노드 셀렉션(이미지 클릭 등) — DOM 캐럿이 없으므로 네이티브
646
476
  // 키 동작은 모두 무시하고 모델에서 처리한다. Backspace/Delete는
647
477
  // 노드 삭제, Enter는 노드 뒤에 새 블록, 화살표/Escape는 텍스트
648
478
  // 셀렉션으로 붕괴, 인쇄 문자는 노드 뒤에 삽입(노드는 유지).
649
479
  // Mod/Alt 조합(undo·키맵 등)은 아래 키맵 루프에 넘긴다
650
- const ds = editor.state.selection;
651
- if (ds.type === "node") {
652
- const parent = ds.path.slice(0, -1);
653
- const idx = ds.path[ds.path.length - 1] ?? 0;
654
- const before = point(parent, idx);
655
- const after = point(parent, idx + 1);
656
- const tr = editor.state.tr();
657
- if (key === "Backspace" || key === "Delete") {
658
- e.preventDefault();
659
- tr.delete({ anchor: before, focus: after });
660
- editor.dispatch(tr);
661
- }
662
- else if (key === "Enter") {
663
- e.preventDefault();
664
- tr.split(after);
665
- editor.dispatch(tr);
666
- }
667
- else if (key === "Escape" ||
668
- key === "ArrowLeft" ||
669
- key === "ArrowUp") {
670
- e.preventDefault();
671
- editor.dispatch(tr.setSelection(textSelection(before)));
672
- }
673
- else if (key === "ArrowRight" || key === "ArrowDown") {
674
- e.preventDefault();
675
- editor.dispatch(tr.setSelection(textSelection(after)));
676
- }
677
- else if (e.key.length === 1 &&
678
- !e.ctrlKey &&
679
- !e.metaKey &&
680
- !e.altKey) {
681
- e.preventDefault();
682
- tr.insertText(e.key, after);
683
- editor.dispatch(tr);
684
- }
480
+ if (editor.state.selection.type === "node" &&
481
+ nodeSelectionKeydown(editor, e, key)) {
482
+ e.preventDefault();
685
483
  return;
686
484
  }
687
485
  for (const km of editor.keymaps) {
@@ -706,202 +504,10 @@ export function bindEditorInput(editor, el) {
706
504
  // Ctrl+←/→는 비-Mac에서 단어 이동이라 metaKey(Cmd)일 때만 가로챈다
707
505
  if (LINE_NAV_KEYS.has(key) &&
708
506
  (!key.includes("Arrow") || e.metaKey) &&
709
- lineNav(key)) {
507
+ lineNav(editor, el, key)) {
710
508
  e.preventDefault();
711
509
  }
712
510
  };
713
- /**
714
- * caretRangeFromPoint로 시각적 줄 끝/시작의 doc 위치를 계산해 캐럿을 옮긴다.
715
- * 클릭 배치와 같은 Blink 엔진이라 네이티브 줄 경계 계산보다 신뢰할 수 있다.
716
- * Mod-Home/End는 doc 모델 기준 문서 가장자리로 간다. 실패 시 false → 네이티브 폴백.
717
- */
718
- const lineNav = (key) => {
719
- const doc = el.ownerDocument;
720
- // DOM 셀렉션 우선 — 클릭 직후엔 doc 셀렉션이 아직 동기화 전이라
721
- // stale할 수 있다. DOM이 분리 노드 위거나(리렌더 직후) 렌더
722
- // 재앵커로 요소 레벨 포인트에 있으면 doc 포커스로 폴백한다
723
- const domNow = readDomSel();
724
- const ds = editor.state.selection;
725
- const docFocus = ds.type === "text" ? ds.focus : null;
726
- const leaf = (p) => !!p && nodeAt(editor.state.doc, p.path)?.type === "text";
727
- const focus = leaf(domNow?.focus) ? domNow.focus : docFocus;
728
- if (!focus)
729
- return false;
730
- const extend = key.startsWith("Shift-") || key.startsWith("Mod-Shift-");
731
- const anchor = () => extend
732
- ? leaf(domNow?.anchor)
733
- ? domNow.anchor
734
- : ds.type === "text"
735
- ? ds.anchor
736
- : null
737
- : null;
738
- // 문서 가장자리 계열(Mod-Home/End) — doc 모델로 바로 적용.
739
- // applySel이 아니라 dispatch로 간다 — doc이 동기 갱신돼야 뒤따르는
740
- // 비동기 렌더 커밋이 DOM을 파괴해도 restore가 같은 위치를 다시
741
- // 적용한다(DOM 직접 쓰기는 pending selchg 사이 커밋에 되돌아간다)
742
- if (key.startsWith("Mod") &&
743
- !key.endsWith("ArrowLeft") &&
744
- !key.endsWith("ArrowRight")) {
745
- const t = key.endsWith("Home")
746
- ? point([], 0)
747
- : point([], editor.state.doc.content?.length ?? 0);
748
- editor.dispatch(editor.state
749
- .tr()
750
- .setSelection({ type: "text", anchor: anchor() ?? t, focus: t }));
751
- return true;
752
- }
753
- // 캐럿의 화면 y — 포커스의 DOM 위치에서 collapsed range rect을 잰다.
754
- // pointToDOM이 현재 DOM으로 재해석하므로 리렌더로 분리된
755
- // 노드 위의 브라우저 셀렉션에 흔들리지 않는다
756
- const domPos = pointToDOM(el, focus);
757
- if (!domPos || !el.contains(domPos.node))
758
- return false;
759
- const range = doc.createRange();
760
- try {
761
- range.setStart(domPos.node, domPos.offset);
762
- range.collapse(true);
763
- }
764
- catch {
765
- return false;
766
- }
767
- let rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
768
- // 줄 시작의 빈/경계 위치 캐럿이나 빈 텍스트 노드(Vue 앵커)에선
769
- // collapsed range가 빈 rect을 준다 — 인접 한 글자 → 부모 요소 순으로
770
- // 근사한다
771
- if ((!rect || rect.height === 0) && domPos.node.nodeType === 3) {
772
- const len = domPos.node.nodeValue?.length ?? 0;
773
- if (domPos.offset < len) {
774
- range.setEnd(domPos.node, domPos.offset + 1);
775
- }
776
- else if (domPos.offset > 0) {
777
- range.setStart(domPos.node, domPos.offset - 1);
778
- }
779
- if (domPos.offset < len || domPos.offset > 0) {
780
- rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
781
- }
782
- }
783
- if (!rect || rect.height === 0) {
784
- const parent = domPos.node.nodeType === 3
785
- ? domPos.node.parentElement
786
- : domPos.node;
787
- rect = parent?.getBoundingClientRect();
788
- }
789
- if (!rect || rect.height === 0)
790
- return false;
791
- // 캐럿이 속한 블록 요소 — 시각적 줄이 갇힌 박스 (인라인 맥락 루트)
792
- let block = domPos.node.nodeType === 3
793
- ? domPos.node.parentElement
794
- : domPos.node;
795
- while (block && block !== el) {
796
- if (getComputedStyle(block).display !== "inline")
797
- break;
798
- block = block.parentElement;
799
- }
800
- const br = (block && block !== el ? block : el).getBoundingClientRect();
801
- const y = rect.top + rect.height / 2;
802
- const t = caretPointFromXY(doc, el, key.endsWith("End") || key.endsWith("ArrowRight")
803
- ? br.right - 1
804
- : br.left + 1, y);
805
- if (!t)
806
- return false;
807
- // 다른 블록의 위치가 잡히면 신뢰 불가 — 네이티브에 맡긴다
808
- const sameBlock = t.path.length === focus.path.length &&
809
- t.path.slice(0, -1).every((v, i) => v === focus.path[i]);
810
- if (!sameBlock)
811
- return false;
812
- editor.dispatch(editor.state
813
- .tr()
814
- .setSelection({ type: "text", anchor: anchor() ?? t, focus: t }));
815
- return true;
816
- };
817
- /**
818
- * composition 중 브라우저가 직접 바꾼 DOM을 doc에 흡수한다.
819
- * 캐럿이 위치한 텍스트 홀더의 DOM 텍스트와 doc 텍스트를 공통
820
- * 접두/접미로 diff해 차이만큼만 트랜잭션으로 반영한다.
821
- * (제약: 조합이 하나의 텍스트 홀더 안에 머문다고 가정 —
822
- * 크로스-블록 조합 삭제는 미지원)
823
- */
824
- const absorbComposition = () => {
825
- const focus = readDomSel()?.focus ?? compStart;
826
- if (!focus || focus.path.length === 0)
827
- return;
828
- const holder = el.querySelector(`[${PATH_ATTR}="${focus.path.join(",")}"]`);
829
- // 텍스트 홀더가 아니면(안에 me-path 자식이 있으면) 흡수 불가
830
- if (!holder || holder.querySelector(`[${PATH_ATTR}]`))
831
- return;
832
- const domText = holder.textContent ?? "";
833
- const target = nodeAt(editor.state.doc, focus.path);
834
- const docText = target?.type === "text" ? target.text ?? "" : "";
835
- if (domText === docText)
836
- return;
837
- let lo = 0;
838
- const max = Math.min(domText.length, docText.length);
839
- while (lo < max && domText[lo] === docText[lo])
840
- lo++;
841
- let hiDom = domText.length;
842
- let hiDoc = docText.length;
843
- while (hiDom > lo && hiDoc > lo && domText[hiDom - 1] === docText[hiDoc - 1]) {
844
- hiDom--;
845
- hiDoc--;
846
- }
847
- const tr = editor.state.tr();
848
- if (hiDoc > lo) {
849
- tr.delete({
850
- anchor: point(focus.path, lo),
851
- focus: point(focus.path, hiDoc),
852
- });
853
- }
854
- const inserted = domText.slice(lo, hiDom);
855
- if (inserted)
856
- tr.insertText(inserted, point(focus.path, lo));
857
- if (!tr.docChanged)
858
- return;
859
- tr.setMeta("history.group", "type").setMeta("ime", true);
860
- // 브라우저가 이 블록의 DOM을 직접 바꿨다 — 리렌더 시 프레임워크
861
- // 리컨실이 깨지지 않게 블록 리마운트 대상으로 표시한다
862
- const dk = topBlockKey(focus) ?? topBlockKey(compStart);
863
- if (dk != null)
864
- dirtyPaths.add(dk);
865
- editor.dispatch(tr);
866
- };
867
- const onCompositionStart = () => {
868
- // 이전 세션의 흡수 타이머가 남아있으면 취소한다 — 그대로 두면 새
869
- // 조합 도중에 flushComposition이 composing을 해제하고 디스패치·
870
- // 리렌더를 일으켜 브라우저가 살아있는 세션을 리셋한다
871
- // ("안녕하세요"→"안ㄴ영ㅎ하세세요" 회귀). 커밋된 텍스트는 DOM에
872
- // 남아 있으므로 다음 flush가 같은 홀더를 diff할 때 함께 흡수된다
873
- if (compTimer !== null) {
874
- clearTimeout(compTimer);
875
- compTimer = null;
876
- }
877
- pendingAbsorb = false;
878
- composing = true;
879
- compStart = readDomSel()?.focus ?? null;
880
- };
881
- /**
882
- * 대기 중인 흡수를 즉시 실행 — 커밋 직후의 구조 입력(Enter 등)이
883
- * 최신 doc 기준으로 처리되게 하기 위해 beforeinput에서 호출한다.
884
- */
885
- const flushComposition = () => {
886
- if (compTimer !== null) {
887
- clearTimeout(compTimer);
888
- compTimer = null;
889
- }
890
- composing = false;
891
- pendingAbsorb = false;
892
- if (editor.editable)
893
- absorbComposition();
894
- compStart = null;
895
- };
896
- const onCompositionEnd = () => {
897
- // composing 해제와 흡수를 다음 태스크로 미룬다 — 같은 태스크의
898
- // 후속 beforeinput/keydown(Safari 최종 커밋, IME Enter 오커밋)을
899
- // composing 가드가 계속 걸러내게 하기 위해서다. 단 그 창에 도착한
900
- // 구조 입력은 flushComposition이 흡수를 앞당겨 정상 처리한다.
901
- pendingAbsorb = true;
902
- compEndedAt = performance.now();
903
- compTimer = setTimeout(flushComposition, 0);
904
- };
905
511
  /**
906
512
  * 체크박스 아이템(taskItem)의 마커 영역 클릭 — ::before 마커는
907
513
  * 의사요소라 이벤트 타겟이 li 자신이 되고, 클릭이 li의 패딩
@@ -928,35 +534,14 @@ export function bindEditorInput(editor, el) {
928
534
  .setNodeAttr(path, "checked", !node?.attrs?.["checked"]));
929
535
  return;
930
536
  }
931
- /**
932
- * atom 노드(img/hr 등) 클릭 → NodeSelection — DOM 캐럿 이동을
933
- * 막고(preventDefault) 모델 셀렉션만 둔다. 오버레이 표시는
934
- * paintOverlays의 is-selected 마킹 + 각 기능의 paintOverlay가 담당.
935
- * 같은 노드를 다시 누르면 트랜잭션을 생략한다
936
- */
937
- if (spec?.atom) {
537
+ // atom 노드(img/hr 등) 클릭 → NodeSelection — DOM 캐럿 이동을
538
+ // 막고(preventDefault) 모델 셀렉션만 둔다. 오버레이 표시는
539
+ // paintOverlays의 is-selected 마킹 + 각 기능의 paintOverlay가 담당.
540
+ // 같은 노드를 다시 누르면 트랜잭션을 생략한다
541
+ if (trySelectAtom(editor, t)) {
938
542
  e.preventDefault();
939
- const cur = editor.state.selection;
940
- if (cur.type === "node" &&
941
- cur.path.length === path.length &&
942
- cur.path.every((v, i) => v === path[i])) {
943
- return;
944
- }
945
- editor.dispatch(editor.state.tr().setSelection(nodeSelection(path)));
946
543
  }
947
544
  };
948
- /**
949
- * 파일 수신 — paste/drop의 File 목록을 기능 훅(files)에 넘긴다.
950
- * 어느 기능도 소비하지 않으면 폴스루: paste는 beforeinput이
951
- * 텍스트/HTML을 처리하고, drop은 브라우저 기본 동작을 따른다.
952
- */
953
- const dispatchFiles = (dt, source, at) => {
954
- const files = Array.from(dt?.files ?? []);
955
- if (!files.length)
956
- return false;
957
- const ctx = { editor, files, at, source };
958
- return editor.inputHooks.some((h) => h.files?.(ctx));
959
- };
960
545
  /**
961
546
  * 붙여넣기 — 클립보드의 파일을 기능 훅으로 위임한다 (이미지 업로드는
962
547
  * imageFeature 소유 — Summernote onImageUpload / Quill 관례).
@@ -964,11 +549,12 @@ export function bindEditorInput(editor, el) {
964
549
  * 처리한다.
965
550
  */
966
551
  const onPaste = (e) => {
967
- if (composing || !editor.editable)
552
+ if (ime.active || !editor.editable)
968
553
  return;
969
554
  // preventDefault하면 beforeinput이 오지 않는다
970
- if (dispatchFiles(e.clipboardData, "paste", null))
555
+ if (dispatchFiles(editor, e.clipboardData, "paste", null)) {
971
556
  e.preventDefault();
557
+ }
972
558
  };
973
559
  /** 드롭을 받으려면 dragover를 먼저 막아야 한다 — 파일(업로드 훅)
974
560
  * 외에 텍스트 드래그도 beforeinput insertFromDrop으로 트랜잭션 처리 */
@@ -981,20 +567,39 @@ export function bindEditorInput(editor, el) {
981
567
  }
982
568
  };
983
569
  const onDrop = (e) => {
984
- if (composing || !editor.editable)
570
+ if (ime.active || !editor.editable)
985
571
  return;
986
572
  const at = caretPointFromXY(el.ownerDocument, el, e.clientX, e.clientY);
987
573
  // 소비되지 않으면 기본 동작(파일을 새 탭으로 여는 것)이 살아있다
988
- if (dispatchFiles(e.dataTransfer, "drop", at))
574
+ if (dispatchFiles(editor, e.dataTransfer, "drop", at))
989
575
  e.preventDefault();
990
576
  };
991
577
  const onSelectionChange = () => {
992
- if (composing)
578
+ if (ime.active)
993
579
  return;
994
580
  // 에디터 안의 셀렉션만 동기화 — 바깥 클릭은 무시
995
581
  const sel = readDomSel();
996
582
  if (!sel)
997
583
  return;
584
+ // 노드 셀렉션 중 도착한 selectionchange — DOM 캐럿은 우리가 노드
585
+ // 경계에 고정한 앵커이거나 선택 전 stale 위치다. 노드 자체의
586
+ // 경계 포인트(앞/뒤)나 마지막 확인 위치와 같으면 사용자가 실제로
587
+ // 움직인 게 아니므로 노드 셀렉션을 유지한다.
588
+ // (DOM 위치가 바뀌었다면 진짜 클릭/이동 — 텍스트 셀렉션으로 붕괴)
589
+ if (editor.state.selection.type === "node") {
590
+ const np = editor.state.selection.path;
591
+ const ni = np[np.length - 1] ?? 0;
592
+ const nparent = np.slice(0, -1);
593
+ const atNodeBoundary = pointEq(sel.anchor, point(nparent, ni)) ||
594
+ pointEq(sel.anchor, point(nparent, ni + 1));
595
+ const unchanged = lastApplied &&
596
+ pointEq(lastApplied.anchor, sel.anchor) &&
597
+ pointEq(lastApplied.focus, sel.focus);
598
+ if (atNodeBoundary || unchanged) {
599
+ paintOverlays();
600
+ return;
601
+ }
602
+ }
998
603
  // 확인한 DOM 위치를 기록 — 이후 변경분만 "사용자 이동"으로 본다
999
604
  lastApplied = sel;
1000
605
  paintOverlays();
@@ -1005,8 +610,14 @@ export function bindEditorInput(editor, el) {
1005
610
  pointEq(cur.focus, next.focus)) {
1006
611
  return;
1007
612
  }
1008
- // 커서 이동은 대기 중인 storedMarks를 지운다 (PM 관례 — []가 클리어)
1009
- editor.dispatch(editor.state.tr().setSelection(next).setStoredMarks([]));
613
+ // 대기 서식(storedMarks)은 커서 이동에도 유지한다 — GDocs/Word 관례
614
+ editor.dispatch(editor.state.tr().setSelection(next));
615
+ };
616
+ // 포커스가 에디터로 돌아오면 건너뛴 DOM 셀렉션을 동기화한다 —
617
+ // 찾기 바에서 내비게이션한 뒤 닫히면 마지막 매치에 캐럿이 선다
618
+ const onFocusIn = () => {
619
+ if (pendingSel)
620
+ restoreSelection();
1010
621
  };
1011
622
  el.addEventListener("beforeinput", onBeforeInput);
1012
623
  el.addEventListener("keydown", onKeydown);
@@ -1014,26 +625,36 @@ export function bindEditorInput(editor, el) {
1014
625
  el.addEventListener("paste", onPaste);
1015
626
  el.addEventListener("dragover", onDragOver);
1016
627
  el.addEventListener("drop", onDrop);
1017
- el.addEventListener("compositionstart", onCompositionStart);
1018
- el.addEventListener("compositionend", onCompositionEnd);
628
+ el.addEventListener("compositionstart", ime.start);
629
+ el.addEventListener("compositionend", ime.end);
630
+ el.addEventListener("focusin", onFocusIn);
1019
631
  el.ownerDocument.addEventListener("selectionchange", onSelectionChange);
632
+ // 오버레이 초기 페인트 — React/Vue2처럼 마운트 layoutEffect가
633
+ // 바인딩보다 먼저 도는 렌더러에서도 첫 커밋 전에 오버레이가
634
+ // 존재하도록 바인딩 시점에 한 번 그린다
635
+ paintOverlays();
1020
636
  return {
1021
637
  dirtyPaths,
1022
638
  unbind() {
1023
- if (compTimer !== null)
1024
- clearTimeout(compTimer);
639
+ ime.destroy();
640
+ // 기능 오버레이 정리 — 거터/크롬 바 DOM + ResizeObserver 해제
641
+ for (const h of editor.inputHooks)
642
+ h.destroyOverlay?.(el);
1025
643
  el.removeEventListener("beforeinput", onBeforeInput);
1026
644
  el.removeEventListener("keydown", onKeydown);
1027
645
  el.removeEventListener("mousedown", onMouseDown);
1028
646
  el.removeEventListener("paste", onPaste);
1029
647
  el.removeEventListener("dragover", onDragOver);
1030
648
  el.removeEventListener("drop", onDrop);
1031
- el.removeEventListener("compositionstart", onCompositionStart);
1032
- el.removeEventListener("compositionend", onCompositionEnd);
649
+ el.removeEventListener("compositionstart", ime.start);
650
+ el.removeEventListener("compositionend", ime.end);
651
+ el.removeEventListener("focusin", onFocusIn);
1033
652
  el.ownerDocument.removeEventListener("selectionchange", onSelectionChange);
1034
653
  },
1035
654
  restoreSelection,
1036
655
  syncDocSelection,
1037
656
  };
1038
657
  }
658
+ // DirtyKeyMap은 dirty-keys.ts로 이동했다 — 공개 API 경로 유지를 위해 재수출
659
+ export { DirtyKeyMap } from "./dirty-keys.js";
1039
660
  //# sourceMappingURL=input.js.map