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