@moda-editor/core 0.3.1 → 0.3.3

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 (137) hide show
  1. package/dist/commands.d.ts.map +1 -1
  2. package/dist/commands.js +14 -1
  3. package/dist/commands.js.map +1 -1
  4. package/dist/editor/editor.d.ts.map +1 -1
  5. package/dist/editor/editor.js +3 -1
  6. package/dist/editor/editor.js.map +1 -1
  7. package/dist/features/code-block.d.ts.map +1 -1
  8. package/dist/features/code-block.js +10 -2
  9. package/dist/features/code-block.js.map +1 -1
  10. package/dist/features/code-edit.d.ts.map +1 -1
  11. package/dist/features/code-edit.js.map +1 -1
  12. package/dist/features/code-format.js.map +1 -1
  13. package/dist/features/hard-break.d.ts.map +1 -1
  14. package/dist/features/hard-break.js +2 -0
  15. package/dist/features/hard-break.js.map +1 -1
  16. package/dist/features/heading.d.ts.map +1 -1
  17. package/dist/features/heading.js +3 -1
  18. package/dist/features/heading.js.map +1 -1
  19. package/dist/features/horizontal-rule.d.ts.map +1 -1
  20. package/dist/features/horizontal-rule.js.map +1 -1
  21. package/dist/features/image.d.ts.map +1 -1
  22. package/dist/features/image.js.map +1 -1
  23. package/dist/features/link.d.ts.map +1 -1
  24. package/dist/features/link.js.map +1 -1
  25. package/dist/features/marks.d.ts.map +1 -1
  26. package/dist/features/marks.js +25 -3
  27. package/dist/features/marks.js.map +1 -1
  28. package/dist/features/search.d.ts.map +1 -1
  29. package/dist/features/search.js +1 -7
  30. package/dist/features/search.js.map +1 -1
  31. package/dist/features/table.d.ts.map +1 -1
  32. package/dist/features/table.js +30 -7
  33. package/dist/features/table.js.map +1 -1
  34. package/dist/fixtures/external-pastes.d.ts +33 -0
  35. package/dist/fixtures/external-pastes.d.ts.map +1 -0
  36. package/dist/fixtures/external-pastes.js +134 -0
  37. package/dist/fixtures/external-pastes.js.map +1 -0
  38. package/dist/history.d.ts.map +1 -1
  39. package/dist/history.js +57 -2
  40. package/dist/history.js.map +1 -1
  41. package/dist/index.d.ts +6 -5
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3 -3
  44. package/dist/index.js.map +1 -1
  45. package/dist/model/node.d.ts.map +1 -1
  46. package/dist/model/node.js +1 -2
  47. package/dist/model/node.js.map +1 -1
  48. package/dist/model/schema.d.ts +7 -0
  49. package/dist/model/schema.d.ts.map +1 -1
  50. package/dist/model/schema.js.map +1 -1
  51. package/dist/model/traverse.d.ts +10 -3
  52. package/dist/model/traverse.d.ts.map +1 -1
  53. package/dist/model/traverse.js +13 -5
  54. package/dist/model/traverse.js.map +1 -1
  55. package/dist/parse/html.d.ts.map +1 -1
  56. package/dist/parse/html.js +33 -3
  57. package/dist/parse/html.js.map +1 -1
  58. package/dist/presets/markdown-parse.d.ts.map +1 -1
  59. package/dist/presets/markdown-parse.js +9 -0
  60. package/dist/presets/markdown-parse.js.map +1 -1
  61. package/dist/presets/markdown.d.ts.map +1 -1
  62. package/dist/presets/markdown.js +7 -6
  63. package/dist/presets/markdown.js.map +1 -1
  64. package/dist/presets/starter.d.ts.map +1 -1
  65. package/dist/presets/starter.js.map +1 -1
  66. package/dist/state/apply.d.ts.map +1 -1
  67. package/dist/state/apply.js +1 -3
  68. package/dist/state/apply.js.map +1 -1
  69. package/dist/state/input-rules.d.ts.map +1 -1
  70. package/dist/state/input-rules.js +4 -0
  71. package/dist/state/input-rules.js.map +1 -1
  72. package/dist/state/insert-ops.d.ts.map +1 -1
  73. package/dist/state/insert-ops.js +9 -2
  74. package/dist/state/insert-ops.js.map +1 -1
  75. package/dist/state/list-ops.d.ts.map +1 -1
  76. package/dist/state/list-ops.js.map +1 -1
  77. package/dist/state/navigate.d.ts.map +1 -1
  78. package/dist/state/navigate.js +14 -4
  79. package/dist/state/navigate.js.map +1 -1
  80. package/dist/state/resolve-insert.d.ts.map +1 -1
  81. package/dist/state/resolve-insert.js +12 -1
  82. package/dist/state/resolve-insert.js.map +1 -1
  83. package/dist/styles.css +27 -12
  84. package/dist/theme.d.ts.map +1 -1
  85. package/dist/theme.js.map +1 -1
  86. package/dist/view/decoration.d.ts.map +1 -1
  87. package/dist/view/decoration.js +60 -44
  88. package/dist/view/decoration.js.map +1 -1
  89. package/dist/view/highlight-registry.d.ts.map +1 -1
  90. package/dist/view/highlight-registry.js.map +1 -1
  91. package/dist/view/ime.d.ts +22 -1
  92. package/dist/view/ime.d.ts.map +1 -1
  93. package/dist/view/ime.js +124 -56
  94. package/dist/view/ime.js.map +1 -1
  95. package/dist/view/input-beforeinput.d.ts +25 -0
  96. package/dist/view/input-beforeinput.d.ts.map +1 -0
  97. package/dist/view/input-beforeinput.js +330 -0
  98. package/dist/view/input-beforeinput.js.map +1 -0
  99. package/dist/view/input-keys.d.ts +25 -0
  100. package/dist/view/input-keys.d.ts.map +1 -0
  101. package/dist/view/input-keys.js +69 -0
  102. package/dist/view/input-keys.js.map +1 -0
  103. package/dist/view/input-pointer.d.ts +24 -0
  104. package/dist/view/input-pointer.d.ts.map +1 -0
  105. package/dist/view/input-pointer.js +96 -0
  106. package/dist/view/input-pointer.js.map +1 -0
  107. package/dist/view/input-selection.d.ts +41 -0
  108. package/dist/view/input-selection.d.ts.map +1 -0
  109. package/dist/view/input-selection.js +219 -0
  110. package/dist/view/input-selection.js.map +1 -0
  111. package/dist/view/input.d.ts +39 -30
  112. package/dist/view/input.d.ts.map +1 -1
  113. package/dist/view/input.js +84 -679
  114. package/dist/view/input.js.map +1 -1
  115. package/dist/view/mount.d.ts +7 -0
  116. package/dist/view/mount.d.ts.map +1 -1
  117. package/dist/view/mount.js +107 -19
  118. package/dist/view/mount.js.map +1 -1
  119. package/dist/view/node-selection.d.ts.map +1 -1
  120. package/dist/view/node-selection.js +1 -3
  121. package/dist/view/node-selection.js.map +1 -1
  122. package/dist/view/render.d.ts.map +1 -1
  123. package/dist/view/render.js +10 -3
  124. package/dist/view/render.js.map +1 -1
  125. package/dist/widget/core.d.ts +37 -0
  126. package/dist/widget/core.d.ts.map +1 -0
  127. package/dist/widget/core.js +117 -0
  128. package/dist/widget/core.js.map +1 -0
  129. package/dist/widget/mount.d.ts +24 -0
  130. package/dist/widget/mount.d.ts.map +1 -0
  131. package/dist/widget/mount.js +45 -0
  132. package/dist/widget/mount.js.map +1 -0
  133. package/dist/widget/types.d.ts +44 -0
  134. package/dist/widget/types.d.ts.map +1 -0
  135. package/dist/widget/types.js +8 -0
  136. package/dist/widget/types.js.map +1 -0
  137. package/package.json +2 -6
@@ -1,685 +1,83 @@
1
- import { nodeAt } from "../model/traverse.js";
2
- import { parseHTML } from "../parse/html.js";
3
- import { pointBefore, pointAfter } from "../state/navigate.js";
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 { blockHasContent, pointBlockPath } from "../commands.js";
12
- import { dispatchFormatInput, LINE_NAV_KEYS, normalizeKey } from "./keys.js";
1
+ import { domSelection } from "./dom.js";
2
+ import { absorbDomText, createImeSession } from "./ime.js";
3
+ import { topBlockKey } from "./caret.js";
4
+ import { createSelectionSync } from "./input-selection.js";
5
+ import { createBeforeInputHandler } from "./input-beforeinput.js";
6
+ import { createKeydownHandler } from "./input-keys.js";
7
+ import { createPointerHandlers } from "./input-pointer.js";
13
8
  export function bindEditorInput(editor, el) {
14
9
  const dirtyPaths = new Set();
15
10
  const readDomSel = () => domSelection(el);
16
- // IME 조합 세션 — composing/compStart/타이머/에코 판별은 전부 세션
17
- // 내부다. 이 파일은 DOM 이벤트를 세션 메서드로 변환하고 판정
18
- // 결과(ImeGate)에 따라 preventDefault/무시만 수행한다
19
- const ime = createImeSession({ editor, el, dirtyPaths, readDomSel });
20
- /**
21
- * 기능 DOM 오버레이 갱신 — 활성 줄 표시·괄호 하이라이트처럼
22
- * 렌더 트리가 아니라 DOM을 직접 마킹하는 것들. 각 기능이
23
- * Extension.input.paintOverlay로 구현하고 여기서 일괄 호출한다 —
24
- * 어댑터 5개가 restoreSelection만 호출하면 동일 동작한다.
25
- */
26
- const paintOverlays = () => {
27
- for (const h of editor.inputHooks) {
28
- h.paintOverlay?.(el, editor.state, editor);
29
- }
30
- // atom 노드 셀렉션 시각화 — 선택 경로의 최외곽 요소에 is-selected를
31
- // 단다. 각 기능의 오버레이(이미지 편집 바 등)와 별개로 공용으로 칠한다
32
- el.querySelectorAll(".is-selected").forEach((n) => n.classList.remove("is-selected"));
33
- const sel = editor.state.selection;
34
- if (sel.type === "node") {
35
- // data-me-path가 안쪽 아울렛(.me-node-content)에 달린 노드 뷰는
36
- // 래퍼(.me-node-view)까지 올라가 컴포넌트 전체에 링을 둘러야 한다
37
- const hit = el.querySelector(`[${PATH_ATTR}="${sel.path.join(",")}"]`);
38
- (hit?.closest(".me-node-view") ?? hit)?.classList.add("is-selected");
39
- }
40
- };
11
+ /** 흡수 flush 직후 콜백 — 조합 창에 미뤄둔 DOM 쓰기를 커밋하는 지점 */
12
+ const idleCbs = new Set();
13
+ // selSync는 ime가 먼저 만들어져야 하므로 늦은 바인딩 — onIdle은
14
+ // 바인딩 완료 뒤의 조합 flush에서만 발화해 null 경로는 도달하지 않는다
15
+ let selSync = null;
16
+ /** IME 세션 — 한 바인딩당 하나. 상태 기계는 ime.ts에 갇혀 있다 */
17
+ const ime = createImeSession({
18
+ editor,
19
+ el,
20
+ dirtyPaths,
21
+ readDomSel,
22
+ onIdle: () => {
23
+ for (const cb of idleCbs)
24
+ cb();
25
+ // 조합 창에 스킵된 셀렉션 복원을 재생한다 — restoreSelection은
26
+ // 조합 중 no-op이라, 흡수가 tr을 만들지 않은(통지 없는) 경로나
27
+ // 셀렉션만 실은 외부 tr에서는 이것이 캐럿을 doc으로 수렴시키는
28
+ // 유일한 복원 지점이다. 어댑터의 post-commit 복원은 이미 DOM과
29
+ // 일치하면 no-op이라 이중 호출 비용이 없다
30
+ selSync?.restoreSelection();
31
+ },
32
+ });
33
+ /** 셀렉션·포커스·오버레이 동기화 — 내부에 lastApplied/pendingSel/
34
+ * focused 상태를 캡슐화한다 */
35
+ const sel = createSelectionSync({ editor, el, dirtyPaths, readDomSel, ime });
36
+ selSync = sel;
37
+ /** DOM 이벤트 핸들러 묶음 — 각 모듈의 팩토리가 의존성을 주입받는다 */
38
+ const onBeforeInput = createBeforeInputHandler({
39
+ editor,
40
+ el,
41
+ readDomSel,
42
+ ime,
43
+ });
44
+ const onKeydown = createKeydownHandler({
45
+ editor,
46
+ el,
47
+ ime,
48
+ syncDocSelection: sel.syncDocSelection,
49
+ });
50
+ const { onMouseDown, onPaste, onDragOver, onDrop } = createPointerHandlers({
51
+ editor,
52
+ el,
53
+ ime,
54
+ });
41
55
  /**
42
- * 우리가 DOM에 마지막으로 반영했거나 selectionchange로 확인한 셀렉션.
43
- * 이후 DOM이 이 값과 다르면 아직 동기화되지 않은 사용자 이동이다 —
44
- * 비동기 렌더 커밋이 stale doc 셀렉션으로 캐럿을 되돌리는 레이스를
45
- * 막기 위해 restoreSelection이 doc 대신 DOM을 진실로 택한다.
56
+ * 네이티브 DOM 변경 폴백 — beforeinput을 preventDefault한 편집은
57
+ * DOM이 안 바뀌어 input을 발생시키지 않으므로, 여기 도달한 input은
58
+ * 우리가 모르는 DOM 변경이다 (iOS 자동고침·받아쓰기처럼 beforeinput이
59
+ * 생략되는 경로). 그대로 두면 doc↔DOM이 영구히 어긋나므로 캐럿
60
+ * 홀더의 텍스트 차이를 트랜잭션으로 흡수하고, 텍스트 diff로 못 잡는
61
+ * 구조 변경은 포커스 블록을 더티 표시해 다음 렌더가 리마운트로
62
+ * 복구하게 한다 (PM DOMObserver의 축소판)
46
63
  */
47
- let lastApplied = null;
48
- const applySel = (anchor, focus) => {
49
- applyDomSelection(el, anchor, focus);
50
- lastApplied = { anchor, focus };
51
- };
52
- // 포커스가 에디터 밖에 있는 동안 건너뛴 DOM 셀렉션 반영 —
53
- // focusin에서 한 번에 동기화한다
54
- let pendingSel = false;
55
- const restoreSelection = () => {
56
- // 더러운 블록이 같은 tr에서 지워져 어댑터가 소비하지 못한 엔트리는
57
- // 버린다 — 인덱스가 재사용되면 엉뚱한 블록이 리마운트된다
58
- const topLen = editor.state.doc.content?.length ?? 0;
59
- for (const k of dirtyPaths) {
60
- const i = Number(k);
61
- if (!Number.isInteger(i) || i < 0 || i >= topLen) {
62
- dirtyPaths.delete(k);
63
- }
64
- }
65
- // compositionend~흡수 사이 창에는 DOM이 브라우저가 쓴 조합
66
- // 텍스트를 품고 있다 — 지연된(비동기) 복원이 이 창에 떨어지면
67
- // 캐럿을 되돌려 흡수가 목표 홀더를 놓친다. 흡수 뒤의 디스패치가
68
- // 다시 복원을 트리거하므로 이 창만 건너뛴다 — 조합 중(composing)엔
69
- // 우리 트랜잭션(크로스-블록 선택 선삭제 등)이 DOM을 바꾸므로
70
- // 복원이 필요하다
71
- if (ime.absorbing)
72
- return;
73
- const sel = editor.state.selection;
74
- // 포커스가 에디터 밖의 다른 요소(찾기 바·팝오버 입력)에 잡혀
75
- // 있으면 DOM 셀렉션을 건드리지 않는다 — Blink는 contenteditable
76
- // 안에 셀렉션을 쓰면 그 요소를 focus하므로, 매 커밋마다 입력
77
- // 포커스를 빼앗긴다 (PM의 hasFocus 관례). 아무것도 포커스하지
78
- // 않은 상태(body)는 기존처럼 반영한다 — 어차피 뺏을 포커스가
79
- // 없고, DOM 셀렉션을 읽는 오버레이(ace 활성 라인)가 동작해야 한다
80
- const ae = el.ownerDocument.activeElement;
81
- if (ae && ae !== el.ownerDocument.body && !el.contains(ae)) {
82
- pendingSel = true;
83
- paintOverlays();
84
- return;
85
- }
86
- pendingSel = false;
87
- if (sel.type !== "text") {
88
- // 노드 셀렉션 — DOM 캐럿을 노드 요소의 경계로 고정한다 (PM 관례).
89
- // 고정하지 않으면 팝 닫힘·포커스 복귀 등으로 브라우저가 캐럿을
90
- // 옮길 때 selectionchange가 그 위치를 읽어 노드 셀렉션을
91
- // 되감는다 — 경계에 고정하면 이후 이벤트는 같은 위치를 읽는다
92
- const hit = el.querySelector(`[${PATH_ATTR}="${sel.path.join(",")}"]`);
93
- const host = hit?.closest(".me-node-view") ?? hit;
94
- const pi = sel.path[sel.path.length - 1];
95
- if (host?.parentNode && pi != null) {
96
- const idx = Array.prototype.indexOf.call(host.parentNode.childNodes, host);
97
- const ds = el.ownerDocument.getSelection();
98
- ds?.setBaseAndExtent(host.parentNode, idx, host.parentNode, idx);
99
- // 실제로 읽히는 Point를 기록 — childNodes 인덱스와 domToPoint의
100
- // 요소 인덱스가 어긋나도 이후 selectionchange는 같은 값을 읽는다
101
- const back = readDomSel();
102
- const bp = point(sel.path.slice(0, -1), pi);
103
- lastApplied = back ?? { anchor: bp, focus: bp };
104
- }
105
- paintOverlays();
106
- return;
107
- }
108
- // 이미 DOM 셀렉션이 doc과 같으면 건드리지 않는다 (깜빡임/역순 방지)
109
- const cur = readDomSel();
110
- if (cur &&
111
- pointEq(cur.anchor, sel.anchor) &&
112
- pointEq(cur.focus, sel.focus)) {
113
- paintOverlays();
114
- return;
115
- }
116
- // DOM이 마지막 반영값과 다르다 = 사용자가 움직였지만 selectionchange가
117
- // 아직 처리되지 않았다 — 비동기 렌더 커밋이 stale doc으로 캐럿을
118
- // 되돌리는 레이스를 막는다. 단 두 조건이 모두 필요하다:
119
- // - doc이 마지막 반영값 그대로 — 트랜잭션이 doc을 바꿨다면 그게
120
- // 최신 의도라 DOM의 이전 위치는 버려야 한다(방금 dispatch된
121
- // setSel이 먹지 않는 역효과 방지)
122
- // - 리프 포인트 — 렌더가 노드를 교체하면 Chrome이 캐럿을 요소
123
- // 경계로 재앵커하는데 그건 사용자 이동이 아니라 폐기 대상이다
124
- if (cur &&
125
- lastApplied &&
126
- isLeafPoint(editor.state.doc, cur.anchor) &&
127
- isLeafPoint(editor.state.doc, cur.focus) &&
128
- pointEq(sel.anchor, lastApplied.anchor) &&
129
- pointEq(sel.focus, lastApplied.focus) &&
130
- (!pointEq(cur.anchor, lastApplied.anchor) ||
131
- !pointEq(cur.focus, lastApplied.focus))) {
132
- lastApplied = cur;
133
- editor.dispatch(editor.state.tr().setSelection({ type: "text", ...cur }));
134
- paintOverlays();
135
- return;
136
- }
137
- applySel(sel.anchor, sel.focus);
138
- paintOverlays();
139
- };
140
- const syncDocSelection = () => {
141
- const s = readDomSel();
142
- if (!s)
143
- return;
144
- // 흡수한 DOM 위치를 확인된 값으로 기록 — 이 위치에 대한 지연
145
- // selectionchange(클릭 직후 키 입력 등)가 도착해도 "사용자가
146
- // 새로 움직인 셀렉션"으로 오인하지 않게 한다 (노드 셀렉션 붕괴 방지)
147
- lastApplied = s;
148
- const cur = editor.state.selection;
149
- if (cur.type === "text" &&
150
- pointEq(cur.anchor, s.anchor) &&
151
- pointEq(cur.focus, s.focus)) {
64
+ const onInput = (e) => {
65
+ // 조합 경로는 흡수가 담당한다 — 조합 중/흡수 대기 창의 input과
66
+ // isComposing input은 건드리지 않는다
67
+ if (ime.active || e.isComposing || !editor.editable) {
152
68
  return;
153
69
  }
154
- // 대기 서식(storedMarks)은 커서 이동에도 유지한다 — GDocs/Word
155
- // 관례: 토글로 끌 때까지 다음 타이핑에 계속 적용된다
156
- editor.dispatch(editor.state.tr().setSelection({ type: "text", ...s }));
157
- };
158
- const onBeforeInput = (e) => {
159
- if (!editor.editable || e.isComposing)
160
- return;
161
- // DOM 셀렉션을 흡수 플러시 전에 읽어둔다 — 커밋 끝점 좌표라
162
- // 흡수된 텍스트를 포함해 정확하고, 플러시의 리렌더가 DOM
163
- // 셀렉션을 doc(조합 시작점)으로 되돌리기 전의 값이다
164
70
  const domSel = readDomSel();
165
- // 조합 세션 게이트 — swallow(조합 중 무시)/prevent(커밋 에코 차단)/
166
- // proceed(정상 처리 — 흡수 대기 중의 구조 입력은 플러시 후 통과)
167
- const gated = ime.gate(e, domSel);
168
- if (gated === "swallow")
71
+ if (!domSel)
169
72
  return;
170
- if (gated === "prevent") {
171
- e.preventDefault();
172
- return;
173
- }
174
- const ds = editor.state.selection;
175
- // 노드 셀렉션 — DOM 캐럿은 클릭 전 위치에 멈춰 있어 신뢰 불가.
176
- // 삭제는 노드를 통째로 지우고, 텍스트/개행 입력은 노드 뒤 블록
177
- // 경계에서 일어나는 것으로 처리한다 (노드는 유지)
178
- if (ds.type === "node") {
179
- e.preventDefault();
180
- nodeSelectionBeforeInput(editor, e);
181
- return;
182
- }
183
- // DOM 셀렉션이 무효하면(리렌더로 분리된 노드 위 등) doc 셀렉션으로
184
- // 폴백 — 여기까지 왔으면 doc 셀렉션은 항상 텍스트다
185
- const sel = domSel ?? { anchor: ds.anchor, focus: ds.focus };
186
- const tr = editor.state.tr();
187
- switch (e.inputType) {
188
- case "insertText":
189
- case "insertCompositionText": {
190
- e.preventDefault();
191
- const data = e.data ?? "";
192
- if (!data)
193
- return;
194
- // 커밋 에코가 흡수 타이머보다 늦게 도착한 경우 — DOM과 doc에
195
- // 이미 같은 글자가 있으므로 다시 넣으면 "세세"가 된다
196
- if (ime.isLateEcho(data, domSel))
197
- return;
198
- // 범위 삭제 뒤엔 삭제 전 좌표가 무효 — 정규화한 앞점 기준으로 삽입
199
- const r = normalizeRange(sel);
200
- // 입력 훅 — 기능이 글자 가로채기(코드블록 자동 쌍·타입오버 등)를
201
- // 구현한다. true 반환 = 소비 → 기본 삽입/입력 규칙을 건너뛴다
202
- let consumed = false;
203
- if (editor.inputHooks.length) {
204
- const ctx = blockCtx(editor, r, tr);
205
- if (ctx) {
206
- for (const h of editor.inputHooks) {
207
- if (h.insertText?.(ctx, data, e.inputType)) {
208
- consumed = true;
209
- break;
210
- }
211
- }
212
- }
213
- }
214
- if (!consumed) {
215
- if (!pointEq(r.anchor, r.focus))
216
- tr.delete(r);
217
- tr.insertText(data, r.anchor).setMeta("history.group", "type");
218
- // 마크다운 입력 규칙 — 삽입+변환이 같은 tr이라 하나의 undo 단위
219
- tr.applyInputRules(editor.inputRules);
220
- editor.dispatch(tr);
221
- }
222
- break;
223
- }
224
- case "insertParagraph":
225
- case "insertLineBreak": {
226
- e.preventDefault();
227
- const r = normalizeRange(sel);
228
- // 분기 판단은 삭제 전 문서의 캐럿 블록 기준 — 경계 포인트
229
- // (빈 블록의 {path:블록,offset:0} 관례)도 안쪽 블록으로 내려
230
- // 해석해야 빈 리스트 아이템의 Enter가 lift로 잡힌다
231
- const blockPath = pointBlockPath(editor.state.doc, editor.state.schema, r.anchor);
232
- const parentPath = blockPath?.slice(0, -1);
233
- const parent = parentPath?.length
234
- ? nodeAt(editor.state.doc, parentPath)
235
- : undefined;
236
- const pspec = parent
237
- ? editor.state.schema.nodeSpec(parent.type)
238
- : undefined;
239
- if (!pointEq(r.anchor, r.focus)) {
240
- tr.delete(r);
241
- // 삭제 후에는 tr.selection(삭제 붕괴점) 기준 — 삭제 전 좌표는 무효
242
- tr.split();
243
- editor.dispatch(tr);
244
- }
245
- else {
246
- // 입력 훅 — 기능이 개행 가로채기(코드블록 자동 들여쓰기·
247
- // 블록 탈출 등)를 구현한다. true 반환 = 소비
248
- let consumed = false;
249
- if (editor.inputHooks.length) {
250
- const ctx = blockCtx(editor, r, tr);
251
- if (ctx) {
252
- for (const h of editor.inputHooks) {
253
- if (h.insertParagraph?.(ctx)) {
254
- consumed = true;
255
- break;
256
- }
257
- }
258
- }
259
- }
260
- if (!consumed) {
261
- if (parentPath && pspec?.listItem) {
262
- // 리스트 아이템 — 빈 아이템이면 리스트 탈출, 아니면 아이템 분할.
263
- // 텍스트 길이만 보면 멘션·hardBreak만 담은 아이템이
264
- // "비었다"로 오판돼 Enter에 내용이 통째로 리프트된다
265
- const kids = parent.content ?? [];
266
- const empty = kids.length === 1 &&
267
- !blockHasContent(editor.state.schema, kids[0]);
268
- if (empty) {
269
- tr.liftItem(parentPath);
270
- }
271
- else {
272
- tr.splitItem(parentPath, r.anchor);
273
- }
274
- }
275
- else {
276
- tr.split(r.anchor);
277
- }
278
- editor.dispatch(tr);
279
- }
280
- }
281
- break;
282
- }
283
- case "deleteContentBackward":
284
- case "deleteContentForward": {
285
- e.preventDefault();
286
- if (!pointEq(sel.anchor, sel.focus)) {
287
- tr.delete(sel);
288
- }
289
- else {
290
- // 입력 훅 — collapsed Backspace 가로채기 (코드블록 쌍 삭제 등).
291
- // true 반환 = 소비 → 아래 기본 삭제 경로를 건너뛴다
292
- let consumed = false;
293
- if (e.inputType === "deleteContentBackward" &&
294
- editor.inputHooks.length) {
295
- const ctx = blockCtx(editor, { anchor: sel.anchor, focus: sel.focus }, tr);
296
- if (ctx) {
297
- for (const h of editor.inputHooks) {
298
- if (h.deleteBackward?.(ctx)) {
299
- consumed = true;
300
- break;
301
- }
302
- }
303
- }
304
- }
305
- if (consumed)
306
- break;
307
- // 브라우저가 계산한 실제 삭제 범위 — 이모지(서로게이트
308
- // 페어)·결합문자·ZWJ 클러스터는 코드 유닛 하나만 지우면
309
- // 깨지므로 targetRange를 우선 쓴다. 단, 방향에 맞는 끝점이
310
- // 캐럿이어야 신뢰한다 — Chrome은 블록 경계 Backspace에
311
- // 캐럿 "뒤" 글자 범위를 반환하는 버그가 있어 그대로 지우면
312
- // 머지 대신 다음 글자가 날아간다
313
- const tr0 = e.getTargetRanges?.()[0];
314
- const ta = tr0 && domToPoint(el, tr0.startContainer, tr0.startOffset);
315
- const tf = tr0 && domToPoint(el, tr0.endContainer, tr0.endOffset);
316
- const backward = e.inputType === "deleteContentBackward";
317
- const onCaret = backward
318
- ? tf && pointEq(tf, sel.focus)
319
- : ta && pointEq(ta, sel.focus);
320
- // 끝점이 요소 레벨 포인트(컨테이너 자식 경계, 블록 머지 시 Chrome이
321
- // (P,0) 같은 위치를 줌)면 tr.delete가 해석하지 못한다 — 리프
322
- // 포인트를 주는 pointBefore/After 폴백으로 돌린다
323
- if (ta &&
324
- tf &&
325
- !pointEq(ta, tf) &&
326
- onCaret &&
327
- isLeafPoint(editor.state.doc, ta) &&
328
- isLeafPoint(editor.state.doc, tf)) {
329
- tr.delete(normalizeRange({ anchor: ta, focus: tf }));
330
- tr.setMeta("history.group", "delete");
331
- editor.dispatch(tr);
332
- break;
333
- }
334
- const boundary = e.inputType === "deleteContentBackward"
335
- ? pointBefore(editor.state.doc, sel.focus)
336
- : pointAfter(editor.state.doc, sel.focus);
337
- if (!boundary)
338
- return;
339
- tr.delete(e.inputType === "deleteContentBackward"
340
- ? { anchor: boundary, focus: sel.focus }
341
- : { anchor: sel.focus, focus: boundary });
342
- }
343
- tr.setMeta("history.group", "delete");
344
- editor.dispatch(tr);
345
- break;
346
- }
347
- case "insertFromPaste": {
348
- e.preventDefault();
349
- const html = e.dataTransfer?.getData("text/html");
350
- const text = e.dataTransfer?.getData("text/plain") ?? "";
351
- const r = normalizeRange(sel);
352
- // 기능 훅 — 링크 URL 감싸기·코드블록 순수 텍스트 등.
353
- // 하나라도 소비하면 기본 경로를 건너뛴다
354
- const ctx = rangeCtx(editor, r, tr);
355
- const data = { html, text };
356
- let consumed = false;
357
- for (const h of editor.inputHooks) {
358
- if (h.insertFromPaste?.(ctx, data)) {
359
- consumed = true;
360
- break;
361
- }
362
- }
363
- if (consumed)
364
- break;
365
- if (!pointEq(r.anchor, r.focus))
366
- tr.delete(r);
367
- if (html) {
368
- const nodes = parseHTML(html, editor.state.schema);
369
- if (nodes.length) {
370
- tr.insertSlice(r.anchor, nodes).setMeta("paste", true);
371
- editor.dispatch(tr);
372
- break;
373
- }
374
- }
375
- if (!text)
376
- return;
377
- tr.insertText(text, r.anchor).setMeta("paste", true);
378
- editor.dispatch(tr);
379
- break;
380
- }
381
- case "insertFromDrop":
382
- case "insertReplacementText": {
383
- // 텍스트 드롭·자동고침 — 핸들러가 없으면 브라우저가 DOM을 직접
384
- // 바꿔 doc과 어긋난다. 이미지 파일 드롭은 onDrop이 먼저 잡는다
385
- e.preventDefault();
386
- const data = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
387
- // insertReplacementText는 교체 대상 범위를 준다 (자동고침)
388
- let r = normalizeRange(sel);
389
- if (e.inputType === "insertReplacementText") {
390
- const tr0 = e.getTargetRanges?.()[0];
391
- const a = tr0 && domToPoint(el, tr0.startContainer, tr0.startOffset);
392
- const f = tr0 && domToPoint(el, tr0.endContainer, tr0.endOffset);
393
- if (a && f)
394
- r = normalizeRange({ anchor: a, focus: f });
395
- }
396
- if (!pointEq(r.anchor, r.focus))
397
- tr.delete(r);
398
- if (data) {
399
- tr.insertText(data, r.anchor).setMeta("history.group", "type");
400
- }
401
- editor.dispatch(tr);
402
- break;
403
- }
404
- // 단어/줄 단위·드래그·잘라내기 삭제 — macOS Option-Delete는
405
- // deleteWord*, Ctrl-K는 deleteSoftLine*, Cmd-X는 deleteByCut으로
406
- // 온다. getTargetRanges가 브라우저가 계산한 정확한 범위를 주므로
407
- // 그것을 쓰고, 없으면 현재 선택을 지운다. 잘라내기의 클립보드
408
- // 쓰기는 cut 이벤트의 디폴트라 beforeinput을 막아도 채워진다
409
- case "deleteWordBackward":
410
- case "deleteWordForward":
411
- case "deleteSoftLineBackward":
412
- case "deleteSoftLineForward":
413
- case "deleteEntireSoftLine":
414
- case "deleteHardLineBackward":
415
- case "deleteHardLineForward":
416
- case "deleteByDrag":
417
- case "deleteByCut": {
418
- e.preventDefault();
419
- const tr0 = e.getTargetRanges?.()[0];
420
- const a = tr0 && domToPoint(el, tr0.startContainer, tr0.startOffset);
421
- const f = tr0 && domToPoint(el, tr0.endContainer, tr0.endOffset);
422
- // 캐럿 끝점이 삭제 방향과 맞을 때만 목표 범위를 신뢰한다 —
423
- // 블록 경계에서 Chrome이 반대 방향 범위를 주는 버그가 있다.
424
- // 방향 없는 타입(drag/cut/entire)은 항상 신뢰한다. 끝점이 요소
425
- // 레벨 포인트면 tr.delete가 해석 못하므로 리프만 받는다
426
- const delBack = e.inputType.endsWith("Backward");
427
- const delFwd = e.inputType.endsWith("Forward");
428
- const trusted = a &&
429
- f &&
430
- !pointEq(a, f) &&
431
- isLeafPoint(editor.state.doc, a) &&
432
- isLeafPoint(editor.state.doc, f) &&
433
- (!delBack || pointEq(f, sel.focus)) &&
434
- (!delFwd || pointEq(a, sel.focus));
435
- const r = trusted
436
- ? normalizeRange({ anchor: a, focus: f })
437
- : normalizeRange(sel);
438
- tr.delete(r).setMeta("history.group", "delete");
439
- editor.dispatch(tr);
440
- break;
441
- }
442
- // 모바일·컨텍스트 메뉴·리더의 서식 명령 — 네이티브 execCommand가
443
- // DOM을 직접 바꾸면 어긋나므로 우리 커맨드로 매핑한다
444
- // (매핑 테이블은 keys.ts의 dispatchFormatInput)
445
- case "insertFromYank":
446
- case "insertFromPasteAsQuotation": {
447
- // macOS 킬링 붙여넣기(Ctrl-Y)·인용 붙여넣기 — 있으면 순수
448
- // 텍스트로 삽입한다
449
- e.preventDefault();
450
- const data = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
451
- if (!data)
452
- return;
453
- const r = normalizeRange(sel);
454
- if (!pointEq(r.anchor, r.focus))
455
- tr.delete(r);
456
- tr.insertText(data, r.anchor).setMeta("history.group", "type");
457
- editor.dispatch(tr);
458
- break;
459
- }
460
- default:
461
- // format*/history*/insert*List 등 — 커맨드 디스패치 테이블에 위임
462
- if (dispatchFormatInput(editor, e.inputType)) {
463
- e.preventDefault();
464
- break;
465
- }
466
- // 나머지 타입(insertTranspose, formatFontColor 등)은 우리가
467
- // 처리하지 않는다 — 그대로 두면 브라우저가 DOM만 바꿔 doc과
468
- // 영구히 어긋나므로 기본 동작을 막아 no-op으로 둔다
469
- e.preventDefault();
470
- break;
73
+ if (!absorbDomText({ editor, el, dirtyPaths }, domSel.focus)) {
74
+ const k = topBlockKey(domSel.focus) ?? topBlockKey(domSel.anchor);
75
+ if (k != null)
76
+ dirtyPaths.add(k);
471
77
  }
472
78
  };
473
- /**
474
- * 키맵 — 확장의 keymap을 순서대로 매칭해 첫 true 커맨드가 이긴다.
475
- * false 반환은 다음 바인딩으로 폴스루 (PM keymap 관례).
476
- */
477
- const onKeydown = (e) => {
478
- if (ime.active || e.isComposing || !editor.editable)
479
- return;
480
- const key = normalizeKey(e);
481
- // Mod-A 전체 선택은 직접 처리 — 중첩 편집 호스트(.me-node-view 안의
482
- // contenteditable 아울렛)가 있으면 Chromium의 네이티브 selectAll이
483
- // 셀렉션을 펼치지 못하고 문서 시작으로 collapse한다
484
- if (key === "Mod-a") {
485
- e.preventDefault();
486
- editor.dispatch(editor.state
487
- .tr()
488
- .setSelection(textSelection({ path: [], offset: 0 }, { path: [], offset: editor.state.doc.content?.length ?? 0 })));
489
- return;
490
- }
491
- // 클릭 직후 키 입력처럼 selectionchange가 아직 도착하지 않은 창에는
492
- // doc 셀렉션이 stale하다 — PM domObserver.flush 관례로 키 처리 전에
493
- // DOM 셀렉션을 먼저 흡수한다. 노드 셀렉션 중엔 DOM 캐럿이 무의미한
494
- // stale 위치라 덮어쓰지 않는다
495
- if (editor.state.selection.type !== "node")
496
- syncDocSelection();
497
- // 노드 셀렉션(이미지 클릭 등) — DOM 캐럿이 없으므로 네이티브
498
- // 키 동작은 모두 무시하고 모델에서 처리한다. Backspace/Delete는
499
- // 노드 삭제, Enter는 노드 뒤에 새 블록, 화살표/Escape는 텍스트
500
- // 셀렉션으로 붕괴, 인쇄 문자는 노드 뒤에 삽입(노드는 유지).
501
- // Mod/Alt 조합(undo·키맵 등)은 아래 키맵 루프에 넘긴다
502
- if (editor.state.selection.type === "node" &&
503
- nodeSelectionKeydown(editor, e, key)) {
504
- e.preventDefault();
505
- return;
506
- }
507
- for (const km of editor.keymaps) {
508
- const cmd = km[key];
509
- if (!cmd)
510
- continue;
511
- const tr = editor.state.tr();
512
- const handled = cmd({
513
- state: editor.state,
514
- tr,
515
- dispatch: () => editor.dispatch(tr),
516
- });
517
- if (handled) {
518
- e.preventDefault();
519
- return;
520
- }
521
- }
522
- // Blink은 중첩 인라인(mark용 span+strong 등)이 있는 contenteditable에서
523
- // Home/End·Cmd+←/→의 줄 경계 계산을 확률적으로 놓쳐 캐럿이 안 움직인다
524
- // (실사용의 "두 번 눌러야 함" 원인). 좌표 히트테스트로 시각적 줄 경계를
525
- // 직접 계산해 네이티브 동작을 대체한다 — 실패하면 기본 동작에 맡긴다.
526
- // Ctrl+←/→는 비-Mac에서 단어 이동이라 metaKey(Cmd)일 때만 가로챈다
527
- if (LINE_NAV_KEYS.has(key) &&
528
- (!key.includes("Arrow") || e.metaKey) &&
529
- lineNav(editor, el, key)) {
530
- e.preventDefault();
531
- }
532
- };
533
- /**
534
- * 체크박스 아이템(taskItem)의 마커 영역 클릭 — ::before 마커는
535
- * 의사요소라 이벤트 타겟이 li 자신이 되고, 클릭이 li의 패딩
536
- * 영역(마커 존 ~28px)에 떨어진다. 자식 블록 위 클릭은 p가
537
- * 타겟이므로 li가 data-me-path를 가지면서 타겟이 li인 경우만
538
- * 마커 클릭으로 해석한다.
539
- */
540
- const onMouseDown = (e) => {
541
- if (!editor.editable)
542
- return;
543
- const t = e.target;
544
- if (!(t instanceof Element))
545
- return;
546
- // 노드 뷰 내부 크롬에는 data-me-path가 없다 — 가장 가까운 경로
547
- // 조상으로 해석한다 (trySelectAtom이 atom 노드를 잡는다)
548
- const pathEl = t.hasAttribute(PATH_ATTR)
549
- ? t
550
- : t.closest(`[${PATH_ATTR}]`);
551
- if (!pathEl)
552
- return;
553
- // .me-editor 자신은 data-me-path=""다 — "".split(",") → [NaN]이
554
- // 아니라 [0]으로 매핑돼 배경 클릭이 첫 블록으로 오인된다
555
- const raw = pathEl.getAttribute(PATH_ATTR) ?? "";
556
- const path = raw === "" ? [] : raw.split(",").map(Number);
557
- if (!path.length)
558
- return;
559
- const node = nodeAt(editor.state.doc, path);
560
- const spec = node && editor.state.schema.nodeSpec(node.type);
561
- // 체크박스 마커 판정은 타겟 자신이 li일 때만 — 안쪽 자식 클릭은
562
- // 경로가 자식 것이라 마커 존으로 오인하면 안 된다
563
- if (pathEl === t && spec?.checkbox) {
564
- // offsetX는 패딩 가장자리 기준 — 마커 존 밖이면 무시
565
- if (e.offsetX > 28)
566
- return;
567
- e.preventDefault();
568
- editor.dispatch(editor.state
569
- .tr()
570
- .setNodeAttr(path, "checked", !node?.attrs?.["checked"]));
571
- return;
572
- }
573
- // atom 노드(img/hr 등) 클릭 → NodeSelection — DOM 캐럿 이동을
574
- // 막고(preventDefault) 모델 셀렉션만 둔다. 오버레이 표시는
575
- // paintOverlays의 is-selected 마킹 + 각 기능의 paintOverlay가 담당.
576
- // 같은 노드를 다시 누르면 트랜잭션을 생략한다
577
- if (trySelectAtom(editor, t)) {
578
- e.preventDefault();
579
- }
580
- };
581
- /**
582
- * 붙여넣기 — 클립보드의 파일을 기능 훅으로 위임한다 (이미지 업로드는
583
- * imageFeature 소유 — Summernote onImageUpload / Quill 관례).
584
- * 파일이 없거나 소비되지 않으면 폴스루 → beforeinput이 텍스트/HTML을
585
- * 처리한다.
586
- */
587
- const onPaste = (e) => {
588
- if (ime.active || !editor.editable)
589
- return;
590
- // paste 이벤트는 sanitizer·훅 소비 전 원본 페이로드로 발행한다 —
591
- // 리스너(로깅·커스텀 변환)가 실제 입력을 관측할 수 있는 유일한 지점
592
- editor.emit("paste", {
593
- html: e.clipboardData?.getData("text/html") ?? "",
594
- text: e.clipboardData?.getData("text/plain") ?? "",
595
- event: e,
596
- });
597
- // preventDefault하면 beforeinput이 오지 않는다
598
- if (dispatchFiles(editor, e.clipboardData, "paste", null)) {
599
- e.preventDefault();
600
- }
601
- };
602
- /** 드롭을 받으려면 dragover를 먼저 막아야 한다 — 파일(업로드 훅)
603
- * 외에 텍스트 드래그도 beforeinput insertFromDrop으로 트랜잭션 처리 */
604
- const onDragOver = (e) => {
605
- if (!editor.editable)
606
- return;
607
- const t = e.dataTransfer?.types;
608
- if (t?.includes("Files") || t?.includes("text/plain")) {
609
- e.preventDefault();
610
- }
611
- };
612
- const onDrop = (e) => {
613
- if (ime.active || !editor.editable)
614
- return;
615
- const at = caretPointFromXY(el.ownerDocument, el, e.clientX, e.clientY);
616
- // 소비되지 않으면 기본 동작(파일을 새 탭으로 여는 것)이 살아있다
617
- if (dispatchFiles(editor, e.dataTransfer, "drop", at))
618
- e.preventDefault();
619
- };
620
- const onSelectionChange = () => {
621
- if (ime.active)
622
- return;
623
- // 에디터 안의 셀렉션만 동기화 — 바깥 클릭은 무시
624
- const sel = readDomSel();
625
- if (!sel)
626
- return;
627
- // 노드 셀렉션 중 도착한 selectionchange — DOM 캐럿은 우리가 노드
628
- // 경계에 고정한 앵커이거나 선택 전 stale 위치다. 노드 자체의
629
- // 경계 포인트(앞/뒤)나 마지막 확인 위치와 같으면 사용자가 실제로
630
- // 움직인 게 아니므로 노드 셀렉션을 유지한다.
631
- // (DOM 위치가 바뀌었다면 진짜 클릭/이동 — 텍스트 셀렉션으로 붕괴)
632
- if (editor.state.selection.type === "node") {
633
- const np = editor.state.selection.path;
634
- const ni = np[np.length - 1] ?? 0;
635
- const nparent = np.slice(0, -1);
636
- const atNodeBoundary = pointEq(sel.anchor, point(nparent, ni)) ||
637
- pointEq(sel.anchor, point(nparent, ni + 1));
638
- const unchanged = lastApplied &&
639
- pointEq(lastApplied.anchor, sel.anchor) &&
640
- pointEq(lastApplied.focus, sel.focus);
641
- if (atNodeBoundary || unchanged) {
642
- paintOverlays();
643
- return;
644
- }
645
- }
646
- // 확인한 DOM 위치를 기록 — 이후 변경분만 "사용자 이동"으로 본다
647
- lastApplied = sel;
648
- paintOverlays();
649
- const cur = editor.state.selection;
650
- const next = { type: "text", ...sel };
651
- if (cur.type === "text" &&
652
- pointEq(cur.anchor, next.anchor) &&
653
- pointEq(cur.focus, next.focus)) {
654
- return;
655
- }
656
- // 대기 서식(storedMarks)은 커서 이동에도 유지한다 — GDocs/Word 관례
657
- editor.dispatch(editor.state.tr().setSelection(next));
658
- };
659
- // 포커스가 에디터로 돌아오면 건너뛴 DOM 셀렉션을 동기화한다 —
660
- // 찾기 바에서 내비게이션한 뒤 닫히면 마지막 매치에 캐럿이 선다
661
- const onFocusIn = (e) => {
662
- if (pendingSel)
663
- restoreSelection();
664
- // focus는 "밖→안" 진입에서만 한 번 — 자식 간 이동(노드 뷰 내부
665
- // 입력 등)은 focusin이 다시 떠도 focus 이벤트를 중복 발행하지 않는다
666
- if (!focused) {
667
- focused = true;
668
- editor.emit("focus", { event: e });
669
- }
670
- };
671
- // 포커스가 에디터 밖으로 나갈 때 blur — relatedTarget이 안쪽이면
672
- // 자식 간 이동이라 무시한다. 협업 캐럿 표시 같은 외부 리스너가
673
- // 이 이벤트로 리모트 커서를 지운다
674
- const onFocusOut = (e) => {
675
- const next = e.relatedTarget;
676
- if (next && el.contains(next))
677
- return;
678
- focused = false;
679
- editor.emit("blur", { event: e });
680
- };
681
- let focused = false;
682
79
  el.addEventListener("beforeinput", onBeforeInput);
80
+ el.addEventListener("input", onInput);
683
81
  el.addEventListener("keydown", onKeydown);
684
82
  el.addEventListener("mousedown", onMouseDown);
685
83
  el.addEventListener("paste", onPaste);
@@ -687,21 +85,28 @@ export function bindEditorInput(editor, el) {
687
85
  el.addEventListener("drop", onDrop);
688
86
  el.addEventListener("compositionstart", ime.start);
689
87
  el.addEventListener("compositionend", ime.end);
690
- el.addEventListener("focusin", onFocusIn);
691
- el.addEventListener("focusout", onFocusOut);
692
- el.ownerDocument.addEventListener("selectionchange", onSelectionChange);
88
+ el.addEventListener("focusin", sel.onFocusIn);
89
+ el.addEventListener("focusout", sel.onFocusOut);
90
+ el.ownerDocument.addEventListener("selectionchange", sel.onSelectionChange);
693
91
  // 오버레이 초기 페인트 — React/Vue2처럼 마운트 layoutEffect가
694
92
  // 바인딩보다 먼저 도는 렌더러에서도 첫 커밋 전에 오버레이가
695
93
  // 존재하도록 바인딩 시점에 한 번 그린다
696
- paintOverlays();
94
+ sel.paintOverlays();
697
95
  return {
698
96
  dirtyPaths,
97
+ ime,
98
+ afterComposition(cb) {
99
+ idleCbs.add(cb);
100
+ return () => idleCbs.delete(cb);
101
+ },
699
102
  unbind() {
700
103
  ime.destroy();
104
+ idleCbs.clear();
701
105
  // 기능 오버레이 정리 — 거터/크롬 바 DOM + ResizeObserver 해제
702
106
  for (const h of editor.inputHooks)
703
107
  h.destroyOverlay?.(el);
704
108
  el.removeEventListener("beforeinput", onBeforeInput);
109
+ el.removeEventListener("input", onInput);
705
110
  el.removeEventListener("keydown", onKeydown);
706
111
  el.removeEventListener("mousedown", onMouseDown);
707
112
  el.removeEventListener("paste", onPaste);
@@ -709,12 +114,12 @@ export function bindEditorInput(editor, el) {
709
114
  el.removeEventListener("drop", onDrop);
710
115
  el.removeEventListener("compositionstart", ime.start);
711
116
  el.removeEventListener("compositionend", ime.end);
712
- el.removeEventListener("focusin", onFocusIn);
713
- el.removeEventListener("focusout", onFocusOut);
714
- el.ownerDocument.removeEventListener("selectionchange", onSelectionChange);
117
+ el.removeEventListener("focusin", sel.onFocusIn);
118
+ el.removeEventListener("focusout", sel.onFocusOut);
119
+ el.ownerDocument.removeEventListener("selectionchange", sel.onSelectionChange);
715
120
  },
716
- restoreSelection,
717
- syncDocSelection,
121
+ restoreSelection: sel.restoreSelection,
122
+ syncDocSelection: sel.syncDocSelection,
718
123
  };
719
124
  }
720
125
  // DirtyKeyMap은 dirty-keys.ts로 이동했다 — 공개 API 경로 유지를 위해 재수출