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