@moda-editor/core 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/dist/commands.d.ts +78 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +299 -0
- package/dist/commands.js.map +1 -0
- package/dist/conformance.d.ts +59 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +1609 -0
- package/dist/conformance.js.map +1 -0
- package/dist/core.d.ts +37 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +117 -0
- package/dist/core.js.map +1 -0
- package/dist/editor/editor.d.ts +104 -0
- package/dist/editor/editor.d.ts.map +1 -0
- package/dist/editor/editor.js +204 -0
- package/dist/editor/editor.js.map +1 -0
- package/dist/editor/extension.d.ts +170 -0
- package/dist/editor/extension.d.ts.map +1 -0
- package/dist/editor/extension.js +2 -0
- package/dist/editor/extension.js.map +1 -0
- package/dist/features/ace.d.ts +9 -0
- package/dist/features/ace.d.ts.map +1 -0
- package/dist/features/ace.js +144 -0
- package/dist/features/ace.js.map +1 -0
- package/dist/features/alignment.d.ts +9 -0
- package/dist/features/alignment.d.ts.map +1 -0
- package/dist/features/alignment.js +28 -0
- package/dist/features/alignment.js.map +1 -0
- package/dist/features/blockquote.d.ts +9 -0
- package/dist/features/blockquote.d.ts.map +1 -0
- package/dist/features/blockquote.js +65 -0
- package/dist/features/blockquote.js.map +1 -0
- package/dist/features/code-block.d.ts +77 -0
- package/dist/features/code-block.d.ts.map +1 -0
- package/dist/features/code-block.js +986 -0
- package/dist/features/code-block.js.map +1 -0
- package/dist/features/doc.d.ts +15 -0
- package/dist/features/doc.d.ts.map +1 -0
- package/dist/features/doc.js +79 -0
- package/dist/features/doc.js.map +1 -0
- package/dist/features/feature.d.ts +26 -0
- package/dist/features/feature.d.ts.map +1 -0
- package/dist/features/feature.js +8 -0
- package/dist/features/feature.js.map +1 -0
- package/dist/features/hard-break.d.ts +9 -0
- package/dist/features/hard-break.d.ts.map +1 -0
- package/dist/features/hard-break.js +48 -0
- package/dist/features/hard-break.js.map +1 -0
- package/dist/features/heading.d.ts +3 -0
- package/dist/features/heading.d.ts.map +1 -0
- package/dist/features/heading.js +31 -0
- package/dist/features/heading.js.map +1 -0
- package/dist/features/horizontal-rule.d.ts +9 -0
- package/dist/features/horizontal-rule.d.ts.map +1 -0
- package/dist/features/horizontal-rule.js +64 -0
- package/dist/features/horizontal-rule.js.map +1 -0
- package/dist/features/image.d.ts +12 -0
- package/dist/features/image.d.ts.map +1 -0
- package/dist/features/image.js +116 -0
- package/dist/features/image.js.map +1 -0
- package/dist/features/index.d.ts +31 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +65 -0
- package/dist/features/index.js.map +1 -0
- package/dist/features/inline-style.d.ts +12 -0
- package/dist/features/inline-style.d.ts.map +1 -0
- package/dist/features/inline-style.js +126 -0
- package/dist/features/inline-style.js.map +1 -0
- package/dist/features/link.d.ts +29 -0
- package/dist/features/link.d.ts.map +1 -0
- package/dist/features/link.js +198 -0
- package/dist/features/link.js.map +1 -0
- package/dist/features/lists.d.ts +14 -0
- package/dist/features/lists.d.ts.map +1 -0
- package/dist/features/lists.js +178 -0
- package/dist/features/lists.js.map +1 -0
- package/dist/features/marks.d.ts +6 -0
- package/dist/features/marks.d.ts.map +1 -0
- package/dist/features/marks.js +68 -0
- package/dist/features/marks.js.map +1 -0
- package/dist/features/script.d.ts +2 -0
- package/dist/features/script.d.ts.map +1 -0
- package/dist/features/script.js +55 -0
- package/dist/features/script.js.map +1 -0
- package/dist/features/table.d.ts +20 -0
- package/dist/features/table.d.ts.map +1 -0
- package/dist/features/table.js +195 -0
- package/dist/features/table.js.map +1 -0
- package/dist/features/task-list.d.ts +10 -0
- package/dist/features/task-list.d.ts.map +1 -0
- package/dist/features/task-list.js +68 -0
- package/dist/features/task-list.js.map +1 -0
- package/dist/features/testkit.d.ts +32 -0
- package/dist/features/testkit.d.ts.map +1 -0
- package/dist/features/testkit.js +92 -0
- package/dist/features/testkit.js.map +1 -0
- package/dist/history.d.ts +40 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +192 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/model/node.d.ts +47 -0
- package/dist/model/node.d.ts.map +1 -0
- package/dist/model/node.js +30 -0
- package/dist/model/node.js.map +1 -0
- package/dist/model/schema.d.ts +105 -0
- package/dist/model/schema.d.ts.map +1 -0
- package/dist/model/schema.js +46 -0
- package/dist/model/schema.js.map +1 -0
- package/dist/model/traverse.d.ts +36 -0
- package/dist/model/traverse.d.ts.map +1 -0
- package/dist/model/traverse.js +123 -0
- package/dist/model/traverse.js.map +1 -0
- package/dist/mount.d.ts +24 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +45 -0
- package/dist/mount.js.map +1 -0
- package/dist/parse/html.d.ts +8 -0
- package/dist/parse/html.d.ts.map +1 -0
- package/dist/parse/html.js +247 -0
- package/dist/parse/html.js.map +1 -0
- package/dist/parse/sanitize.d.ts +20 -0
- package/dist/parse/sanitize.d.ts.map +1 -0
- package/dist/parse/sanitize.js +116 -0
- package/dist/parse/sanitize.js.map +1 -0
- package/dist/presets/highlight.d.ts +23 -0
- package/dist/presets/highlight.d.ts.map +1 -0
- package/dist/presets/highlight.js +170 -0
- package/dist/presets/highlight.js.map +1 -0
- package/dist/presets/starter.d.ts +14 -0
- package/dist/presets/starter.d.ts.map +1 -0
- package/dist/presets/starter.js +18 -0
- package/dist/presets/starter.js.map +1 -0
- package/dist/state/apply.d.ts +64 -0
- package/dist/state/apply.d.ts.map +1 -0
- package/dist/state/apply.js +528 -0
- package/dist/state/apply.js.map +1 -0
- package/dist/state/navigate.d.ts +5 -0
- package/dist/state/navigate.d.ts.map +1 -0
- package/dist/state/navigate.js +81 -0
- package/dist/state/navigate.js.map +1 -0
- package/dist/state/selection.d.ts +44 -0
- package/dist/state/selection.d.ts.map +1 -0
- package/dist/state/selection.js +63 -0
- package/dist/state/selection.js.map +1 -0
- package/dist/state/state.d.ts +39 -0
- package/dist/state/state.d.ts.map +1 -0
- package/dist/state/state.js +123 -0
- package/dist/state/state.js.map +1 -0
- package/dist/state/transaction.d.ts +234 -0
- package/dist/state/transaction.d.ts.map +1 -0
- package/dist/state/transaction.js +988 -0
- package/dist/state/transaction.js.map +1 -0
- package/dist/styles.css +948 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/view/dom.d.ts +26 -0
- package/dist/view/dom.d.ts.map +1 -0
- package/dist/view/dom.js +249 -0
- package/dist/view/dom.js.map +1 -0
- package/dist/view/input.d.ts +45 -0
- package/dist/view/input.d.ts.map +1 -0
- package/dist/view/input.js +901 -0
- package/dist/view/input.js.map +1 -0
- package/dist/view/mount.d.ts +26 -0
- package/dist/view/mount.d.ts.map +1 -0
- package/dist/view/mount.js +119 -0
- package/dist/view/mount.js.map +1 -0
- package/dist/view/render.d.ts +64 -0
- package/dist/view/render.d.ts.map +1 -0
- package/dist/view/render.js +203 -0
- package/dist/view/render.js.map +1 -0
- package/dist/view/serialize.d.ts +4 -0
- package/dist/view/serialize.d.ts.map +1 -0
- package/dist/view/serialize.js +37 -0
- package/dist/view/serialize.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,901 @@
|
|
|
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 { 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
|
+
}
|
|
101
|
+
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
|
+
const dirtyPaths = new Set();
|
|
110
|
+
/** p가 속한 최상위 블록 경로 — 프레임워크 리마운트 대상 */
|
|
111
|
+
const topBlockKey = (p) => p?.path[0] == null ? null : String(p.path[0]);
|
|
112
|
+
const readDomSel = () => domSelection(el);
|
|
113
|
+
/**
|
|
114
|
+
* 기능 DOM 오버레이 갱신 — 활성 줄 표시·괄호 하이라이트처럼
|
|
115
|
+
* 렌더 트리가 아니라 DOM을 직접 마킹하는 것들. 각 기능이
|
|
116
|
+
* Extension.input.paintOverlay로 구현하고 여기서 일괄 호출한다 —
|
|
117
|
+
* 어댑터 5개가 restoreSelection만 호출하면 동일 동작한다.
|
|
118
|
+
*/
|
|
119
|
+
const paintOverlays = () => {
|
|
120
|
+
for (const h of editor.inputHooks) {
|
|
121
|
+
h.paintOverlay?.(el, editor.state);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* 우리가 DOM에 마지막으로 반영했거나 selectionchange로 확인한 셀렉션.
|
|
126
|
+
* 이후 DOM이 이 값과 다르면 아직 동기화되지 않은 사용자 이동이다 —
|
|
127
|
+
* 비동기 렌더 커밋이 stale doc 셀렉션으로 캐럿을 되돌리는 레이스를
|
|
128
|
+
* 막기 위해 restoreSelection이 doc 대신 DOM을 진실로 택한다.
|
|
129
|
+
*/
|
|
130
|
+
let lastApplied = null;
|
|
131
|
+
const applySel = (anchor, focus) => {
|
|
132
|
+
applyDomSelection(el, anchor, focus);
|
|
133
|
+
lastApplied = { anchor, focus };
|
|
134
|
+
};
|
|
135
|
+
const restoreSelection = () => {
|
|
136
|
+
const sel = editor.state.selection;
|
|
137
|
+
if (sel.type !== "text")
|
|
138
|
+
return;
|
|
139
|
+
// 이미 DOM 셀렉션이 doc과 같으면 건드리지 않는다 (깜빡임/역순 방지)
|
|
140
|
+
const cur = readDomSel();
|
|
141
|
+
if (cur &&
|
|
142
|
+
pointEq(cur.anchor, sel.anchor) &&
|
|
143
|
+
pointEq(cur.focus, sel.focus)) {
|
|
144
|
+
paintOverlays();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// DOM이 마지막 반영값과 다르다 = 사용자가 움직였지만 selectionchange가
|
|
148
|
+
// 아직 처리되지 않았다 — 비동기 렌더 커밋이 stale doc으로 캐럿을
|
|
149
|
+
// 되돌리는 레이스를 막는다. 단 두 조건이 모두 필요하다:
|
|
150
|
+
// - doc이 마지막 반영값 그대로 — 트랜잭션이 doc을 바꿨다면 그게
|
|
151
|
+
// 최신 의도라 DOM의 이전 위치는 버려야 한다(방금 dispatch된
|
|
152
|
+
// setSel이 먹지 않는 역효과 방지)
|
|
153
|
+
// - 리프 포인트 — 렌더가 노드를 교체하면 Chrome이 캐럿을 요소
|
|
154
|
+
// 경계로 재앵커하는데 그건 사용자 이동이 아니라 폐기 대상이다
|
|
155
|
+
const isLeaf = (p) => nodeAt(editor.state.doc, p.path)?.type === "text";
|
|
156
|
+
if (cur &&
|
|
157
|
+
lastApplied &&
|
|
158
|
+
isLeaf(cur.anchor) &&
|
|
159
|
+
isLeaf(cur.focus) &&
|
|
160
|
+
pointEq(sel.anchor, lastApplied.anchor) &&
|
|
161
|
+
pointEq(sel.focus, lastApplied.focus) &&
|
|
162
|
+
(!pointEq(cur.anchor, lastApplied.anchor) ||
|
|
163
|
+
!pointEq(cur.focus, lastApplied.focus))) {
|
|
164
|
+
lastApplied = cur;
|
|
165
|
+
editor.dispatch(editor.state
|
|
166
|
+
.tr()
|
|
167
|
+
.setSelection({ type: "text", ...cur })
|
|
168
|
+
.setStoredMarks([]));
|
|
169
|
+
paintOverlays();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
applySel(sel.anchor, sel.focus);
|
|
173
|
+
paintOverlays();
|
|
174
|
+
};
|
|
175
|
+
const syncDocSelection = () => {
|
|
176
|
+
const s = readDomSel();
|
|
177
|
+
if (!s)
|
|
178
|
+
return;
|
|
179
|
+
const cur = editor.state.selection;
|
|
180
|
+
if (cur.type === "text" &&
|
|
181
|
+
pointEq(cur.anchor, s.anchor) &&
|
|
182
|
+
pointEq(cur.focus, s.focus)) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// 커서 이동은 대기 중인 storedMarks를 지운다 (PM 관례 — []가 클리어)
|
|
186
|
+
editor.dispatch(editor.state
|
|
187
|
+
.tr()
|
|
188
|
+
.setSelection({ type: "text", ...s })
|
|
189
|
+
.setStoredMarks([]));
|
|
190
|
+
};
|
|
191
|
+
const onBeforeInput = (e) => {
|
|
192
|
+
if (!editor.editable || e.isComposing)
|
|
193
|
+
return;
|
|
194
|
+
// DOM 셀렉션을 흡수 플러시 전에 읽어둔다 — 커밋 끝점 좌표라
|
|
195
|
+
// 흡수된 텍스트를 포함해 정확하고, 플러시의 리렌더가 DOM
|
|
196
|
+
// 셀렉션을 doc(조합 시작점)으로 되돌리기 전의 값이다
|
|
197
|
+
const domSel = readDomSel();
|
|
198
|
+
if (composing) {
|
|
199
|
+
// 조합 종료 직후(흡수 대기) 도착한 구조 입력만 통과 — 커밋-Enter의
|
|
200
|
+
// insertParagraph가 대표적이다. 텍스트 계열은 계속 브라우저에
|
|
201
|
+
// 맡겨 DOM이 바뀌게 두고 타이머의 흡수가 잡는다
|
|
202
|
+
const structural = e.inputType === "insertParagraph" ||
|
|
203
|
+
e.inputType === "insertLineBreak" ||
|
|
204
|
+
e.inputType === "insertFromPaste" ||
|
|
205
|
+
e.inputType === "insertFromDrop" ||
|
|
206
|
+
e.inputType.startsWith("delete");
|
|
207
|
+
if (!pendingAbsorb || !structural)
|
|
208
|
+
return;
|
|
209
|
+
flushComposition();
|
|
210
|
+
}
|
|
211
|
+
// DOM 셀렉션이 무효하면(리렌더로 분리된 노드 위 등) doc 셀렉션으로
|
|
212
|
+
// 폴백 — sel 없이 리턴하면 preventDefault도 안 걸려 브라우저가
|
|
213
|
+
// 직접 DOM을 바꾸고 doc과 영구히 어긋난다. doc 셀렉션이 텍스트가
|
|
214
|
+
// 아닌 경우(노드 선택 등)도 마찬가지라 막아둔다
|
|
215
|
+
const ds = editor.state.selection;
|
|
216
|
+
const sel = domSel ??
|
|
217
|
+
(ds.type === "text" ? { anchor: ds.anchor, focus: ds.focus } : null);
|
|
218
|
+
if (!sel) {
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const tr = editor.state.tr();
|
|
223
|
+
switch (e.inputType) {
|
|
224
|
+
case "insertText":
|
|
225
|
+
case "insertCompositionText": {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
const data = e.data ?? "";
|
|
228
|
+
if (!data)
|
|
229
|
+
return;
|
|
230
|
+
// 범위 삭제 뒤엔 삭제 전 좌표가 무효 — 정규화한 앞점 기준으로 삽입
|
|
231
|
+
const r = normalizeRange(sel);
|
|
232
|
+
// 입력 훅 — 기능이 글자 가로채기(코드블록 자동 쌍·타입오버 등)를
|
|
233
|
+
// 구현한다. true 반환 = 소비 → 기본 삽입/입력 규칙을 건너뛴다
|
|
234
|
+
let consumed = false;
|
|
235
|
+
if (editor.inputHooks.length) {
|
|
236
|
+
const ctx = blockCtx(editor, r, tr);
|
|
237
|
+
if (ctx) {
|
|
238
|
+
for (const h of editor.inputHooks) {
|
|
239
|
+
if (h.insertText?.(ctx, data, e.inputType)) {
|
|
240
|
+
consumed = true;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (!consumed) {
|
|
247
|
+
if (!pointEq(r.anchor, r.focus))
|
|
248
|
+
tr.delete(r);
|
|
249
|
+
tr.insertText(data, r.anchor).setMeta("history.group", "type");
|
|
250
|
+
// 마크다운 입력 규칙 — 삽입+변환이 같은 tr이라 하나의 undo 단위
|
|
251
|
+
tr.applyInputRules(editor.inputRules);
|
|
252
|
+
editor.dispatch(tr);
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case "insertParagraph":
|
|
257
|
+
case "insertLineBreak": {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
const r = normalizeRange(sel);
|
|
260
|
+
// 분기 판단은 삭제 전 문서의 캐럿 블록 기준
|
|
261
|
+
const blockPath = r.anchor.path.slice(0, -1);
|
|
262
|
+
const parentPath = blockPath.slice(0, -1);
|
|
263
|
+
const parent = parentPath.length
|
|
264
|
+
? nodeAt(editor.state.doc, parentPath)
|
|
265
|
+
: undefined;
|
|
266
|
+
const pspec = parent
|
|
267
|
+
? editor.state.schema.nodeSpec(parent.type)
|
|
268
|
+
: undefined;
|
|
269
|
+
if (!pointEq(r.anchor, r.focus)) {
|
|
270
|
+
tr.delete(r);
|
|
271
|
+
// 삭제 후에는 tr.selection(삭제 붕괴점) 기준 — 삭제 전 좌표는 무효
|
|
272
|
+
tr.split();
|
|
273
|
+
editor.dispatch(tr);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// 입력 훅 — 기능이 개행 가로채기(코드블록 자동 들여쓰기·
|
|
277
|
+
// 블록 탈출 등)를 구현한다. true 반환 = 소비
|
|
278
|
+
let consumed = false;
|
|
279
|
+
if (editor.inputHooks.length) {
|
|
280
|
+
const ctx = blockCtx(editor, r, tr);
|
|
281
|
+
if (ctx) {
|
|
282
|
+
for (const h of editor.inputHooks) {
|
|
283
|
+
if (h.insertParagraph?.(ctx)) {
|
|
284
|
+
consumed = true;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (!consumed) {
|
|
291
|
+
if (pspec?.listItem) {
|
|
292
|
+
// 리스트 아이템 — 빈 아이템이면 리스트 탈출, 아니면 아이템 분할
|
|
293
|
+
const kids = parent.content ?? [];
|
|
294
|
+
const empty = kids.length === 1 &&
|
|
295
|
+
!(kids[0].content ?? []).some((c) => c.text?.length);
|
|
296
|
+
if (empty) {
|
|
297
|
+
tr.liftItem(parentPath);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
tr.splitItem(parentPath, r.anchor);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
tr.split(r.anchor);
|
|
305
|
+
}
|
|
306
|
+
editor.dispatch(tr);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
case "deleteContentBackward":
|
|
312
|
+
case "deleteContentForward": {
|
|
313
|
+
e.preventDefault();
|
|
314
|
+
if (!pointEq(sel.anchor, sel.focus)) {
|
|
315
|
+
tr.delete(sel);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
// 입력 훅 — collapsed Backspace 가로채기 (코드블록 쌍 삭제 등).
|
|
319
|
+
// true 반환 = 소비 → 아래 기본 삭제 경로를 건너뛴다
|
|
320
|
+
let consumed = false;
|
|
321
|
+
if (e.inputType === "deleteContentBackward" &&
|
|
322
|
+
editor.inputHooks.length) {
|
|
323
|
+
const ctx = blockCtx(editor, { anchor: sel.anchor, focus: sel.focus }, tr);
|
|
324
|
+
if (ctx) {
|
|
325
|
+
for (const h of editor.inputHooks) {
|
|
326
|
+
if (h.deleteBackward?.(ctx)) {
|
|
327
|
+
consumed = true;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (consumed)
|
|
334
|
+
break;
|
|
335
|
+
// 브라우저가 계산한 실제 삭제 범위 — 이모지(서로게이트
|
|
336
|
+
// 페어)·결합문자·ZWJ 클러스터는 코드 유닛 하나만 지우면
|
|
337
|
+
// 깨지므로 targetRange를 우선 쓴다. 단, 방향에 맞는 끝점이
|
|
338
|
+
// 캐럿이어야 신뢰한다 — Chrome은 블록 경계 Backspace에
|
|
339
|
+
// 캐럿 "뒤" 글자 범위를 반환하는 버그가 있어 그대로 지우면
|
|
340
|
+
// 머지 대신 다음 글자가 날아간다
|
|
341
|
+
const tr0 = e.getTargetRanges?.()[0];
|
|
342
|
+
const ta = tr0 && domToPoint(el, tr0.startContainer, tr0.startOffset);
|
|
343
|
+
const tf = tr0 && domToPoint(el, tr0.endContainer, tr0.endOffset);
|
|
344
|
+
const backward = e.inputType === "deleteContentBackward";
|
|
345
|
+
const onCaret = backward
|
|
346
|
+
? tf && pointEq(tf, sel.focus)
|
|
347
|
+
: ta && pointEq(ta, sel.focus);
|
|
348
|
+
// 끝점이 요소 레벨 포인트(컨테이너 자식 경계, 블록 머지 시 Chrome이
|
|
349
|
+
// (P,0) 같은 위치를 줌)면 tr.delete가 해석하지 못한다 — 리프
|
|
350
|
+
// 포인트를 주는 pointBefore/After 폴백으로 돌린다
|
|
351
|
+
const leaf = (p) => !!p && nodeAt(editor.state.doc, p.path)?.type === "text";
|
|
352
|
+
if (ta && tf && !pointEq(ta, tf) && onCaret && leaf(ta) && leaf(tf)) {
|
|
353
|
+
tr.delete(normalizeRange({ anchor: ta, focus: tf }));
|
|
354
|
+
tr.setMeta("history.group", "delete");
|
|
355
|
+
editor.dispatch(tr);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
const boundary = e.inputType === "deleteContentBackward"
|
|
359
|
+
? pointBefore(editor.state.doc, sel.focus)
|
|
360
|
+
: pointAfter(editor.state.doc, sel.focus);
|
|
361
|
+
if (!boundary)
|
|
362
|
+
return;
|
|
363
|
+
tr.delete(e.inputType === "deleteContentBackward"
|
|
364
|
+
? { anchor: boundary, focus: sel.focus }
|
|
365
|
+
: { anchor: sel.focus, focus: boundary });
|
|
366
|
+
}
|
|
367
|
+
tr.setMeta("history.group", "delete");
|
|
368
|
+
editor.dispatch(tr);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
case "insertFromPaste": {
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
const html = e.dataTransfer?.getData("text/html");
|
|
374
|
+
const text = e.dataTransfer?.getData("text/plain") ?? "";
|
|
375
|
+
const r = normalizeRange(sel);
|
|
376
|
+
// 기능 훅 — 링크 URL 감싸기·코드블록 순수 텍스트 등.
|
|
377
|
+
// 하나라도 소비하면 기본 경로를 건너뛴다
|
|
378
|
+
const ctx = {
|
|
379
|
+
state: editor.state,
|
|
380
|
+
tr,
|
|
381
|
+
dispatch: () => editor.dispatch(tr),
|
|
382
|
+
anchor: r.anchor,
|
|
383
|
+
focus: r.focus,
|
|
384
|
+
};
|
|
385
|
+
const data = { html, text };
|
|
386
|
+
let consumed = false;
|
|
387
|
+
for (const h of editor.inputHooks) {
|
|
388
|
+
if (h.insertFromPaste?.(ctx, data)) {
|
|
389
|
+
consumed = true;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (consumed)
|
|
394
|
+
break;
|
|
395
|
+
if (!pointEq(r.anchor, r.focus))
|
|
396
|
+
tr.delete(r);
|
|
397
|
+
if (html) {
|
|
398
|
+
const nodes = parseHTML(html, editor.state.schema);
|
|
399
|
+
if (nodes.length) {
|
|
400
|
+
tr.insertSlice(r.anchor, nodes).setMeta("paste", true);
|
|
401
|
+
editor.dispatch(tr);
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (!text)
|
|
406
|
+
return;
|
|
407
|
+
tr.insertText(text, r.anchor).setMeta("paste", true);
|
|
408
|
+
editor.dispatch(tr);
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case "insertFromDrop":
|
|
412
|
+
case "insertReplacementText": {
|
|
413
|
+
// 텍스트 드롭·자동고침 — 핸들러가 없으면 브라우저가 DOM을 직접
|
|
414
|
+
// 바꿔 doc과 어긋난다. 이미지 파일 드롭은 onDrop이 먼저 잡는다
|
|
415
|
+
e.preventDefault();
|
|
416
|
+
const data = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
|
|
417
|
+
// insertReplacementText는 교체 대상 범위를 준다 (자동고침)
|
|
418
|
+
let r = normalizeRange(sel);
|
|
419
|
+
if (e.inputType === "insertReplacementText") {
|
|
420
|
+
const tr0 = e.getTargetRanges?.()[0];
|
|
421
|
+
const a = tr0 &&
|
|
422
|
+
domToPoint(el, tr0.startContainer, tr0.startOffset);
|
|
423
|
+
const f = tr0 && domToPoint(el, tr0.endContainer, tr0.endOffset);
|
|
424
|
+
if (a && f)
|
|
425
|
+
r = normalizeRange({ anchor: a, focus: f });
|
|
426
|
+
}
|
|
427
|
+
if (!pointEq(r.anchor, r.focus))
|
|
428
|
+
tr.delete(r);
|
|
429
|
+
if (data) {
|
|
430
|
+
tr.insertText(data, r.anchor).setMeta("history.group", "type");
|
|
431
|
+
}
|
|
432
|
+
editor.dispatch(tr);
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case "historyUndo":
|
|
436
|
+
case "historyRedo": {
|
|
437
|
+
// iOS/Android의 OS 실행취소·재실행 제스처 — 네이티브가 DOM을
|
|
438
|
+
// 되돌리면 doc과 어긋나니 우리 히스토리로 돌린다
|
|
439
|
+
e.preventDefault();
|
|
440
|
+
editor.commands[e.inputType === "historyUndo" ? "undo" : "redo"]?.();
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
// 단어/줄 단위·드래그·잘라내기 삭제 — macOS Option-Delete는
|
|
444
|
+
// deleteWord*, Ctrl-K는 deleteSoftLine*, Cmd-X는 deleteByCut으로
|
|
445
|
+
// 온다. getTargetRanges가 브라우저가 계산한 정확한 범위를 주므로
|
|
446
|
+
// 그것을 쓰고, 없으면 현재 선택을 지운다. 잘라내기의 클립보드
|
|
447
|
+
// 쓰기는 cut 이벤트의 디폴트라 beforeinput을 막아도 채워진다
|
|
448
|
+
case "deleteWordBackward":
|
|
449
|
+
case "deleteWordForward":
|
|
450
|
+
case "deleteSoftLineBackward":
|
|
451
|
+
case "deleteSoftLineForward":
|
|
452
|
+
case "deleteEntireSoftLine":
|
|
453
|
+
case "deleteHardLineBackward":
|
|
454
|
+
case "deleteHardLineForward":
|
|
455
|
+
case "deleteByDrag":
|
|
456
|
+
case "deleteByCut": {
|
|
457
|
+
e.preventDefault();
|
|
458
|
+
const tr0 = e.getTargetRanges?.()[0];
|
|
459
|
+
const a = tr0 && domToPoint(el, tr0.startContainer, tr0.startOffset);
|
|
460
|
+
const f = tr0 && domToPoint(el, tr0.endContainer, tr0.endOffset);
|
|
461
|
+
// 캐럿 끝점이 삭제 방향과 맞을 때만 목표 범위를 신뢰한다 —
|
|
462
|
+
// 블록 경계에서 Chrome이 반대 방향 범위를 주는 버그가 있다.
|
|
463
|
+
// 방향 없는 타입(drag/cut/entire)은 항상 신뢰한다. 끝점이 요소
|
|
464
|
+
// 레벨 포인트면 tr.delete가 해석 못하므로 리프만 받는다
|
|
465
|
+
const delBack = e.inputType.endsWith("Backward");
|
|
466
|
+
const delFwd = e.inputType.endsWith("Forward");
|
|
467
|
+
const leafP = (p) => !!p && nodeAt(editor.state.doc, p.path)?.type === "text";
|
|
468
|
+
const trusted = a &&
|
|
469
|
+
f &&
|
|
470
|
+
!pointEq(a, f) &&
|
|
471
|
+
leafP(a) &&
|
|
472
|
+
leafP(f) &&
|
|
473
|
+
(!delBack || pointEq(f, sel.focus)) &&
|
|
474
|
+
(!delFwd || pointEq(a, sel.focus));
|
|
475
|
+
const r = trusted
|
|
476
|
+
? normalizeRange({ anchor: a, focus: f })
|
|
477
|
+
: normalizeRange(sel);
|
|
478
|
+
tr.delete(r).setMeta("history.group", "delete");
|
|
479
|
+
editor.dispatch(tr);
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
// 모바일·컨텍스트 메뉴·리더의 서식 명령 — 네이티브 execCommand가
|
|
483
|
+
// DOM을 직접 바꾸면 어긋나므로 우리 커맨드로 매핑한다
|
|
484
|
+
case "formatBold":
|
|
485
|
+
case "formatItalic":
|
|
486
|
+
case "formatUnderline":
|
|
487
|
+
case "formatStrikeThrough":
|
|
488
|
+
case "formatSuperscript":
|
|
489
|
+
case "formatSubscript": {
|
|
490
|
+
e.preventDefault();
|
|
491
|
+
const mark = FORMAT_MARKS[e.inputType];
|
|
492
|
+
if (editor.state.schema.markSpec(mark)) {
|
|
493
|
+
toggleMark(mark)({
|
|
494
|
+
state: editor.state,
|
|
495
|
+
tr,
|
|
496
|
+
dispatch: () => editor.dispatch(tr),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
case "formatRemove": {
|
|
502
|
+
e.preventDefault();
|
|
503
|
+
editor.commands.clearFormat?.();
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
case "formatJustifyLeft":
|
|
507
|
+
case "formatJustifyCenter":
|
|
508
|
+
case "formatJustifyRight": {
|
|
509
|
+
e.preventDefault();
|
|
510
|
+
const align = {
|
|
511
|
+
formatJustifyLeft: "left",
|
|
512
|
+
formatJustifyCenter: "center",
|
|
513
|
+
formatJustifyRight: "right",
|
|
514
|
+
}[e.inputType];
|
|
515
|
+
editor.commands.setAlign?.(align);
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
case "formatIndent":
|
|
519
|
+
case "formatOutdent": {
|
|
520
|
+
e.preventDefault();
|
|
521
|
+
editor.commands[e.inputType === "formatIndent" ? "sinkListItem" : "liftListItem"]?.();
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
case "insertUnorderedList":
|
|
525
|
+
case "insertOrderedList": {
|
|
526
|
+
e.preventDefault();
|
|
527
|
+
editor.commands[e.inputType === "insertOrderedList"
|
|
528
|
+
? "toggleOrderedList"
|
|
529
|
+
: "toggleBulletList"]?.();
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
case "insertHorizontalRule": {
|
|
533
|
+
e.preventDefault();
|
|
534
|
+
editor.commands.insertHorizontalRule?.();
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
case "insertFromYank":
|
|
538
|
+
case "insertFromPasteAsQuotation": {
|
|
539
|
+
// macOS 킬링 붙여넣기(Ctrl-Y)·인용 붙여넣기 — 있으면 순수
|
|
540
|
+
// 텍스트로 삽입한다
|
|
541
|
+
e.preventDefault();
|
|
542
|
+
const data = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
|
|
543
|
+
if (!data)
|
|
544
|
+
return;
|
|
545
|
+
const r = normalizeRange(sel);
|
|
546
|
+
if (!pointEq(r.anchor, r.focus))
|
|
547
|
+
tr.delete(r);
|
|
548
|
+
tr.insertText(data, r.anchor).setMeta("history.group", "type");
|
|
549
|
+
editor.dispatch(tr);
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
// 나머지 타입(insertTranspose, formatFontColor 등)은 우리가
|
|
553
|
+
// 처리하지 않는다 — 그대로 두면 브라우저가 DOM만 바꿔 doc과
|
|
554
|
+
// 영구히 어긋나므로 기본 동작을 막아 no-op으로 둔다
|
|
555
|
+
default:
|
|
556
|
+
e.preventDefault();
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
/**
|
|
561
|
+
* 키맵 — 확장의 keymap을 순서대로 매칭해 첫 true 커맨드가 이긴다.
|
|
562
|
+
* false 반환은 다음 바인딩으로 폴스루 (PM keymap 관례).
|
|
563
|
+
*/
|
|
564
|
+
const onKeydown = (e) => {
|
|
565
|
+
if (composing || e.isComposing || !editor.editable)
|
|
566
|
+
return;
|
|
567
|
+
const key = normalizeKey(e);
|
|
568
|
+
// Mod-A 전체 선택은 직접 처리 — 중첩 편집 호스트(.me-node-view 안의
|
|
569
|
+
// contenteditable 아울렛)가 있으면 Chromium의 네이티브 selectAll이
|
|
570
|
+
// 셀렉션을 펼치지 못하고 문서 시작으로 collapse한다
|
|
571
|
+
if (key === "Mod-a") {
|
|
572
|
+
e.preventDefault();
|
|
573
|
+
editor.dispatch(editor.state.tr().setSelection(textSelection({ path: [], offset: 0 }, { path: [], offset: editor.state.doc.content?.length ?? 0 })));
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
for (const km of editor.keymaps) {
|
|
577
|
+
const cmd = km[key];
|
|
578
|
+
if (!cmd)
|
|
579
|
+
continue;
|
|
580
|
+
const tr = editor.state.tr();
|
|
581
|
+
const handled = cmd({
|
|
582
|
+
state: editor.state,
|
|
583
|
+
tr,
|
|
584
|
+
dispatch: () => editor.dispatch(tr),
|
|
585
|
+
});
|
|
586
|
+
if (handled) {
|
|
587
|
+
e.preventDefault();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// Blink은 중첩 인라인(mark용 span+strong 등)이 있는 contenteditable에서
|
|
592
|
+
// Home/End·Cmd+←/→의 줄 경계 계산을 확률적으로 놓쳐 캐럿이 안 움직인다
|
|
593
|
+
// (실사용의 "두 번 눌러야 함" 원인). 좌표 히트테스트로 시각적 줄 경계를
|
|
594
|
+
// 직접 계산해 네이티브 동작을 대체한다 — 실패하면 기본 동작에 맡긴다.
|
|
595
|
+
// Ctrl+←/→는 비-Mac에서 단어 이동이라 metaKey(Cmd)일 때만 가로챈다
|
|
596
|
+
if (LINE_NAV_KEYS.has(key) &&
|
|
597
|
+
(!key.includes("Arrow") || e.metaKey) &&
|
|
598
|
+
lineNav(key)) {
|
|
599
|
+
e.preventDefault();
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
/**
|
|
603
|
+
* caretRangeFromPoint로 시각적 줄 끝/시작의 doc 위치를 계산해 캐럿을 옮긴다.
|
|
604
|
+
* 클릭 배치와 같은 Blink 엔진이라 네이티브 줄 경계 계산보다 신뢰할 수 있다.
|
|
605
|
+
* Mod-Home/End는 doc 모델 기준 문서 가장자리로 간다. 실패 시 false → 네이티브 폴백.
|
|
606
|
+
*/
|
|
607
|
+
const lineNav = (key) => {
|
|
608
|
+
const doc = el.ownerDocument;
|
|
609
|
+
// DOM 셀렉션 우선 — 클릭 직후엔 doc 셀렉션이 아직 동기화 전이라
|
|
610
|
+
// stale할 수 있다. DOM이 분리 노드 위거나(리렌더 직후) 렌더
|
|
611
|
+
// 재앵커로 요소 레벨 포인트에 있으면 doc 포커스로 폴백한다
|
|
612
|
+
const domNow = readDomSel();
|
|
613
|
+
const ds = editor.state.selection;
|
|
614
|
+
const docFocus = ds.type === "text" ? ds.focus : null;
|
|
615
|
+
const leaf = (p) => !!p && nodeAt(editor.state.doc, p.path)?.type === "text";
|
|
616
|
+
const focus = leaf(domNow?.focus) ? domNow.focus : docFocus;
|
|
617
|
+
if (!focus)
|
|
618
|
+
return false;
|
|
619
|
+
const extend = key.startsWith("Shift-") || key.startsWith("Mod-Shift-");
|
|
620
|
+
const anchor = () => extend
|
|
621
|
+
? leaf(domNow?.anchor)
|
|
622
|
+
? domNow.anchor
|
|
623
|
+
: ds.type === "text"
|
|
624
|
+
? ds.anchor
|
|
625
|
+
: null
|
|
626
|
+
: null;
|
|
627
|
+
// 문서 가장자리 계열(Mod-Home/End) — doc 모델로 바로 적용.
|
|
628
|
+
// applySel이 아니라 dispatch로 간다 — doc이 동기 갱신돼야 뒤따르는
|
|
629
|
+
// 비동기 렌더 커밋이 DOM을 파괴해도 restore가 같은 위치를 다시
|
|
630
|
+
// 적용한다(DOM 직접 쓰기는 pending selchg 사이 커밋에 되돌아간다)
|
|
631
|
+
if (key.startsWith("Mod") &&
|
|
632
|
+
!key.endsWith("ArrowLeft") &&
|
|
633
|
+
!key.endsWith("ArrowRight")) {
|
|
634
|
+
const t = key.endsWith("Home")
|
|
635
|
+
? point([], 0)
|
|
636
|
+
: point([], editor.state.doc.content?.length ?? 0);
|
|
637
|
+
editor.dispatch(editor.state
|
|
638
|
+
.tr()
|
|
639
|
+
.setSelection({ type: "text", anchor: anchor() ?? t, focus: t }));
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
// 캐럿의 화면 y — 포커스의 DOM 위치에서 collapsed range rect을 잰다.
|
|
643
|
+
// pointToDOM이 현재 DOM으로 재해석하므로 리렌더로 분리된
|
|
644
|
+
// 노드 위의 브라우저 셀렉션에 흔들리지 않는다
|
|
645
|
+
const domPos = pointToDOM(el, focus);
|
|
646
|
+
if (!domPos || !el.contains(domPos.node))
|
|
647
|
+
return false;
|
|
648
|
+
const range = doc.createRange();
|
|
649
|
+
try {
|
|
650
|
+
range.setStart(domPos.node, domPos.offset);
|
|
651
|
+
range.collapse(true);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
let rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
|
|
657
|
+
// 줄 시작의 빈/경계 위치 캐럿이나 빈 텍스트 노드(Vue 앵커)에선
|
|
658
|
+
// collapsed range가 빈 rect을 준다 — 인접 한 글자 → 부모 요소 순으로
|
|
659
|
+
// 근사한다
|
|
660
|
+
if ((!rect || rect.height === 0) && domPos.node.nodeType === 3) {
|
|
661
|
+
const len = domPos.node.nodeValue?.length ?? 0;
|
|
662
|
+
if (domPos.offset < len) {
|
|
663
|
+
range.setEnd(domPos.node, domPos.offset + 1);
|
|
664
|
+
}
|
|
665
|
+
else if (domPos.offset > 0) {
|
|
666
|
+
range.setStart(domPos.node, domPos.offset - 1);
|
|
667
|
+
}
|
|
668
|
+
if (domPos.offset < len || domPos.offset > 0) {
|
|
669
|
+
rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (!rect || rect.height === 0) {
|
|
673
|
+
const parent = domPos.node.nodeType === 3
|
|
674
|
+
? domPos.node.parentElement
|
|
675
|
+
: domPos.node;
|
|
676
|
+
rect = parent?.getBoundingClientRect();
|
|
677
|
+
}
|
|
678
|
+
if (!rect || rect.height === 0)
|
|
679
|
+
return false;
|
|
680
|
+
// 캐럿이 속한 블록 요소 — 시각적 줄이 갇힌 박스 (인라인 맥락 루트)
|
|
681
|
+
let block = domPos.node.nodeType === 3
|
|
682
|
+
? domPos.node.parentElement
|
|
683
|
+
: domPos.node;
|
|
684
|
+
while (block && block !== el) {
|
|
685
|
+
if (getComputedStyle(block).display !== "inline")
|
|
686
|
+
break;
|
|
687
|
+
block = block.parentElement;
|
|
688
|
+
}
|
|
689
|
+
const br = (block && block !== el ? block : el).getBoundingClientRect();
|
|
690
|
+
const y = rect.top + rect.height / 2;
|
|
691
|
+
const t = caretPointFromXY(doc, el, key.endsWith("End") || key.endsWith("ArrowRight")
|
|
692
|
+
? br.right - 1
|
|
693
|
+
: br.left + 1, y);
|
|
694
|
+
if (!t)
|
|
695
|
+
return false;
|
|
696
|
+
// 다른 블록의 위치가 잡히면 신뢰 불가 — 네이티브에 맡긴다
|
|
697
|
+
const sameBlock = t.path.length === focus.path.length &&
|
|
698
|
+
t.path.slice(0, -1).every((v, i) => v === focus.path[i]);
|
|
699
|
+
if (!sameBlock)
|
|
700
|
+
return false;
|
|
701
|
+
editor.dispatch(editor.state
|
|
702
|
+
.tr()
|
|
703
|
+
.setSelection({ type: "text", anchor: anchor() ?? t, focus: t }));
|
|
704
|
+
return true;
|
|
705
|
+
};
|
|
706
|
+
/**
|
|
707
|
+
* composition 중 브라우저가 직접 바꾼 DOM을 doc에 흡수한다.
|
|
708
|
+
* 캐럿이 위치한 텍스트 홀더의 DOM 텍스트와 doc 텍스트를 공통
|
|
709
|
+
* 접두/접미로 diff해 차이만큼만 트랜잭션으로 반영한다.
|
|
710
|
+
* (제약: 조합이 하나의 텍스트 홀더 안에 머문다고 가정 —
|
|
711
|
+
* 크로스-블록 조합 삭제는 미지원)
|
|
712
|
+
*/
|
|
713
|
+
const absorbComposition = () => {
|
|
714
|
+
const focus = readDomSel()?.focus ?? compStart;
|
|
715
|
+
if (!focus || focus.path.length === 0)
|
|
716
|
+
return;
|
|
717
|
+
const holder = el.querySelector(`[${PATH_ATTR}="${focus.path.join(",")}"]`);
|
|
718
|
+
// 텍스트 홀더가 아니면(안에 me-path 자식이 있으면) 흡수 불가
|
|
719
|
+
if (!holder || holder.querySelector(`[${PATH_ATTR}]`))
|
|
720
|
+
return;
|
|
721
|
+
const domText = holder.textContent ?? "";
|
|
722
|
+
const target = nodeAt(editor.state.doc, focus.path);
|
|
723
|
+
const docText = target?.type === "text" ? target.text ?? "" : "";
|
|
724
|
+
if (domText === docText)
|
|
725
|
+
return;
|
|
726
|
+
let lo = 0;
|
|
727
|
+
const max = Math.min(domText.length, docText.length);
|
|
728
|
+
while (lo < max && domText[lo] === docText[lo])
|
|
729
|
+
lo++;
|
|
730
|
+
let hiDom = domText.length;
|
|
731
|
+
let hiDoc = docText.length;
|
|
732
|
+
while (hiDom > lo && hiDoc > lo && domText[hiDom - 1] === docText[hiDoc - 1]) {
|
|
733
|
+
hiDom--;
|
|
734
|
+
hiDoc--;
|
|
735
|
+
}
|
|
736
|
+
const tr = editor.state.tr();
|
|
737
|
+
if (hiDoc > lo) {
|
|
738
|
+
tr.delete({
|
|
739
|
+
anchor: point(focus.path, lo),
|
|
740
|
+
focus: point(focus.path, hiDoc),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
const inserted = domText.slice(lo, hiDom);
|
|
744
|
+
if (inserted)
|
|
745
|
+
tr.insertText(inserted, point(focus.path, lo));
|
|
746
|
+
if (!tr.docChanged)
|
|
747
|
+
return;
|
|
748
|
+
tr.setMeta("history.group", "type").setMeta("ime", true);
|
|
749
|
+
// 브라우저가 이 블록의 DOM을 직접 바꿨다 — 리렌더 시 프레임워크
|
|
750
|
+
// 리컨실이 깨지지 않게 블록 리마운트 대상으로 표시한다
|
|
751
|
+
const dk = topBlockKey(focus) ?? topBlockKey(compStart);
|
|
752
|
+
if (dk != null)
|
|
753
|
+
dirtyPaths.add(dk);
|
|
754
|
+
editor.dispatch(tr);
|
|
755
|
+
};
|
|
756
|
+
const onCompositionStart = () => {
|
|
757
|
+
composing = true;
|
|
758
|
+
pendingAbsorb = false;
|
|
759
|
+
compStart = readDomSel()?.focus ?? null;
|
|
760
|
+
};
|
|
761
|
+
/**
|
|
762
|
+
* 대기 중인 흡수를 즉시 실행 — 커밋 직후의 구조 입력(Enter 등)이
|
|
763
|
+
* 최신 doc 기준으로 처리되게 하기 위해 beforeinput에서 호출한다.
|
|
764
|
+
*/
|
|
765
|
+
const flushComposition = () => {
|
|
766
|
+
if (compTimer !== null) {
|
|
767
|
+
clearTimeout(compTimer);
|
|
768
|
+
compTimer = null;
|
|
769
|
+
}
|
|
770
|
+
composing = false;
|
|
771
|
+
pendingAbsorb = false;
|
|
772
|
+
if (editor.editable)
|
|
773
|
+
absorbComposition();
|
|
774
|
+
compStart = null;
|
|
775
|
+
};
|
|
776
|
+
const onCompositionEnd = () => {
|
|
777
|
+
// composing 해제와 흡수를 다음 태스크로 미룬다 — 같은 태스크의
|
|
778
|
+
// 후속 beforeinput/keydown(Safari 최종 커밋, IME Enter 오커밋)을
|
|
779
|
+
// composing 가드가 계속 걸러내게 하기 위해서다. 단 그 창에 도착한
|
|
780
|
+
// 구조 입력은 flushComposition이 흡수를 앞당겨 정상 처리한다.
|
|
781
|
+
pendingAbsorb = true;
|
|
782
|
+
compTimer = setTimeout(flushComposition, 0);
|
|
783
|
+
};
|
|
784
|
+
/**
|
|
785
|
+
* 체크박스 아이템(taskItem)의 마커 영역 클릭 — ::before 마커는
|
|
786
|
+
* 의사요소라 이벤트 타겟이 li 자신이 되고, 클릭이 li의 패딩
|
|
787
|
+
* 영역(마커 존 ~28px)에 떨어진다. 자식 블록 위 클릭은 p가
|
|
788
|
+
* 타겟이므로 li가 data-me-path를 가지면서 타겟이 li인 경우만
|
|
789
|
+
* 마커 클릭으로 해석한다.
|
|
790
|
+
*/
|
|
791
|
+
const onMouseDown = (e) => {
|
|
792
|
+
if (!editor.editable)
|
|
793
|
+
return;
|
|
794
|
+
const t = e.target;
|
|
795
|
+
if (!(t instanceof Element) || !t.hasAttribute(PATH_ATTR))
|
|
796
|
+
return;
|
|
797
|
+
const path = t.getAttribute(PATH_ATTR).split(",").map(Number);
|
|
798
|
+
const node = nodeAt(editor.state.doc, path);
|
|
799
|
+
const spec = node && editor.state.schema.nodeSpec(node.type);
|
|
800
|
+
if (!spec?.checkbox)
|
|
801
|
+
return;
|
|
802
|
+
// offsetX는 패딩 가장자리 기준 — 마커 존 밖이면 무시
|
|
803
|
+
if (e.offsetX > 28)
|
|
804
|
+
return;
|
|
805
|
+
e.preventDefault();
|
|
806
|
+
editor.dispatch(editor.state
|
|
807
|
+
.tr()
|
|
808
|
+
.setNodeAttr(path, "checked", !node?.attrs?.["checked"]));
|
|
809
|
+
};
|
|
810
|
+
/**
|
|
811
|
+
* 파일 수신 — paste/drop의 File 목록을 기능 훅(files)에 넘긴다.
|
|
812
|
+
* 어느 기능도 소비하지 않으면 폴스루: paste는 beforeinput이
|
|
813
|
+
* 텍스트/HTML을 처리하고, drop은 브라우저 기본 동작을 따른다.
|
|
814
|
+
*/
|
|
815
|
+
const dispatchFiles = (dt, source, at) => {
|
|
816
|
+
const files = Array.from(dt?.files ?? []);
|
|
817
|
+
if (!files.length)
|
|
818
|
+
return false;
|
|
819
|
+
const ctx = { editor, files, at, source };
|
|
820
|
+
return editor.inputHooks.some((h) => h.files?.(ctx));
|
|
821
|
+
};
|
|
822
|
+
/**
|
|
823
|
+
* 붙여넣기 — 클립보드의 파일을 기능 훅으로 위임한다 (이미지 업로드는
|
|
824
|
+
* imageFeature 소유 — Summernote onImageUpload / Quill 관례).
|
|
825
|
+
* 파일이 없거나 소비되지 않으면 폴스루 → beforeinput이 텍스트/HTML을
|
|
826
|
+
* 처리한다.
|
|
827
|
+
*/
|
|
828
|
+
const onPaste = (e) => {
|
|
829
|
+
if (composing || !editor.editable)
|
|
830
|
+
return;
|
|
831
|
+
// preventDefault하면 beforeinput이 오지 않는다
|
|
832
|
+
if (dispatchFiles(e.clipboardData, "paste", null))
|
|
833
|
+
e.preventDefault();
|
|
834
|
+
};
|
|
835
|
+
/** 드롭을 받으려면 dragover를 먼저 막아야 한다 — 파일(업로드 훅)
|
|
836
|
+
* 외에 텍스트 드래그도 beforeinput insertFromDrop으로 트랜잭션 처리 */
|
|
837
|
+
const onDragOver = (e) => {
|
|
838
|
+
if (!editor.editable)
|
|
839
|
+
return;
|
|
840
|
+
const t = e.dataTransfer?.types;
|
|
841
|
+
if (t?.includes("Files") || t?.includes("text/plain")) {
|
|
842
|
+
e.preventDefault();
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
const onDrop = (e) => {
|
|
846
|
+
if (composing || !editor.editable)
|
|
847
|
+
return;
|
|
848
|
+
const at = caretPointFromXY(el.ownerDocument, el, e.clientX, e.clientY);
|
|
849
|
+
// 소비되지 않으면 기본 동작(파일을 새 탭으로 여는 것)이 살아있다
|
|
850
|
+
if (dispatchFiles(e.dataTransfer, "drop", at))
|
|
851
|
+
e.preventDefault();
|
|
852
|
+
};
|
|
853
|
+
const onSelectionChange = () => {
|
|
854
|
+
if (composing)
|
|
855
|
+
return;
|
|
856
|
+
// 에디터 안의 셀렉션만 동기화 — 바깥 클릭은 무시
|
|
857
|
+
const sel = readDomSel();
|
|
858
|
+
if (!sel)
|
|
859
|
+
return;
|
|
860
|
+
// 확인한 DOM 위치를 기록 — 이후 변경분만 "사용자 이동"으로 본다
|
|
861
|
+
lastApplied = sel;
|
|
862
|
+
paintOverlays();
|
|
863
|
+
const cur = editor.state.selection;
|
|
864
|
+
const next = { type: "text", ...sel };
|
|
865
|
+
if (cur.type === "text" &&
|
|
866
|
+
pointEq(cur.anchor, next.anchor) &&
|
|
867
|
+
pointEq(cur.focus, next.focus)) {
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
// 커서 이동은 대기 중인 storedMarks를 지운다 (PM 관례 — []가 클리어)
|
|
871
|
+
editor.dispatch(editor.state.tr().setSelection(next).setStoredMarks([]));
|
|
872
|
+
};
|
|
873
|
+
el.addEventListener("beforeinput", onBeforeInput);
|
|
874
|
+
el.addEventListener("keydown", onKeydown);
|
|
875
|
+
el.addEventListener("mousedown", onMouseDown);
|
|
876
|
+
el.addEventListener("paste", onPaste);
|
|
877
|
+
el.addEventListener("dragover", onDragOver);
|
|
878
|
+
el.addEventListener("drop", onDrop);
|
|
879
|
+
el.addEventListener("compositionstart", onCompositionStart);
|
|
880
|
+
el.addEventListener("compositionend", onCompositionEnd);
|
|
881
|
+
el.ownerDocument.addEventListener("selectionchange", onSelectionChange);
|
|
882
|
+
return {
|
|
883
|
+
dirtyPaths,
|
|
884
|
+
unbind() {
|
|
885
|
+
if (compTimer !== null)
|
|
886
|
+
clearTimeout(compTimer);
|
|
887
|
+
el.removeEventListener("beforeinput", onBeforeInput);
|
|
888
|
+
el.removeEventListener("keydown", onKeydown);
|
|
889
|
+
el.removeEventListener("mousedown", onMouseDown);
|
|
890
|
+
el.removeEventListener("paste", onPaste);
|
|
891
|
+
el.removeEventListener("dragover", onDragOver);
|
|
892
|
+
el.removeEventListener("drop", onDrop);
|
|
893
|
+
el.removeEventListener("compositionstart", onCompositionStart);
|
|
894
|
+
el.removeEventListener("compositionend", onCompositionEnd);
|
|
895
|
+
el.ownerDocument.removeEventListener("selectionchange", onSelectionChange);
|
|
896
|
+
},
|
|
897
|
+
restoreSelection,
|
|
898
|
+
syncDocSelection,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
//# sourceMappingURL=input.js.map
|