@moda-editor/core 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +304 -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 +169 -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 +345 -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/editor.d.ts.map +1 -1
- package/dist/editor/editor.js +18 -1
- package/dist/editor/editor.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 +59 -20
- 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 +84 -1034
- 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 +16 -0
- package/dist/features/doc.d.ts.map +1 -1
- package/dist/features/doc.js +154 -2
- 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 +3 -1
- package/dist/features/image.d.ts.map +1 -1
- package/dist/features/image.js +27 -9
- 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 +76 -0
- package/dist/features/search.d.ts.map +1 -0
- package/dist/features/search.js +281 -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 +21 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -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 +46 -0
- package/dist/presets/markdown.d.ts.map +1 -0
- package/dist/presets/markdown.js +196 -0
- package/dist/presets/markdown.js.map +1 -0
- package/dist/state/apply.d.ts.map +1 -1
- package/dist/state/apply.js +25 -13
- 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 +401 -213
- package/dist/theme.d.ts +29 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +347 -0
- package/dist/theme.js.map +1 -0
- 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 +156 -535
- 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 +22 -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
package/dist/conformance.js
CHANGED
|
@@ -1,1749 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
import { parseHTML } from "./parse/html.js";
|
|
5
|
-
import { starterExtensions } from "./presets/starter.js";
|
|
6
|
-
import { point, pointEq, textSelection } from "./state/selection.js";
|
|
7
|
-
import { leafPoint } from "./state/apply.js";
|
|
8
|
-
/** 마운트 헬퍼 — 매 테스트마다 새 픽스처+새 인스턴스 */
|
|
9
|
-
async function mountSpecWidget(mount) {
|
|
10
|
-
const items = [
|
|
11
|
-
{ id: 1, label: "사과" },
|
|
12
|
-
{ id: 2, label: "바나나" },
|
|
13
|
-
{ id: 3, label: "사과파이" },
|
|
14
|
-
];
|
|
15
|
-
const m = await mount({
|
|
16
|
-
items,
|
|
17
|
-
getItemText: (i) => i.label,
|
|
18
|
-
});
|
|
19
|
-
const flush = async () => {
|
|
20
|
-
await m.flush?.();
|
|
21
|
-
};
|
|
22
|
-
return { ...m, items, flush };
|
|
23
|
-
}
|
|
24
|
-
export function runWidgetConformance(label, mount) {
|
|
25
|
-
describe(`${label} 렌더 계약`, () => {
|
|
26
|
-
it("검색 입력·목록·카운터를 렌더한다", async () => {
|
|
27
|
-
const { el, destroy } = await mountSpecWidget(mount);
|
|
28
|
-
expect(el.querySelector(".me-search")).toBeTruthy();
|
|
29
|
-
expect(el.querySelectorAll(".me-item").length).toBe(3);
|
|
30
|
-
expect(el.querySelector(".me-count")?.textContent).toContain("3");
|
|
31
|
-
destroy();
|
|
32
|
-
});
|
|
33
|
-
it("검색 입력에 타이핑하면 목록이 필터된다", async () => {
|
|
34
|
-
const { el, flush, destroy } = await mountSpecWidget(mount);
|
|
35
|
-
const input = el.querySelector(".me-search");
|
|
36
|
-
// React의 controlled input은 .value 직접 대입을 추적하지 못한다 —
|
|
37
|
-
// 네이티브 setter로 우회해 input 이벤트가 실제 타이핑처럼 보이게 한다
|
|
38
|
-
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
|
|
39
|
-
setValue.call(input, "사과");
|
|
40
|
-
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
41
|
-
await flush();
|
|
42
|
-
// Vue2는 보간 주변에 공백을 렌더한다 — trim으로 렌더러 편차를 흡수
|
|
43
|
-
const labels = [...el.querySelectorAll(".me-item")].map((n) => n.textContent?.trim());
|
|
44
|
-
expect(labels).toEqual(["사과", "사과파이"]);
|
|
45
|
-
expect(el.querySelector(".me-count")?.textContent).toContain("2");
|
|
46
|
-
destroy();
|
|
47
|
-
});
|
|
48
|
-
it("destroy 후 루트를 제거한다", async () => {
|
|
49
|
-
const { el, destroy } = await mountSpecWidget(mount);
|
|
50
|
-
destroy();
|
|
51
|
-
expect(el.querySelector(".me-list")).toBeNull();
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
/** 스펙용 에디터 픽스처 — 스타터 스키마 + 2블록 문서 */
|
|
56
|
-
function specEditor() {
|
|
57
|
-
const doc = createDoc([
|
|
58
|
-
{
|
|
59
|
-
type: "paragraph",
|
|
60
|
-
content: [createText("안녕"), createText(" 굵게", [{ type: "bold" }])],
|
|
61
|
-
},
|
|
62
|
-
{ type: "paragraph", content: [createText("둘째")] },
|
|
63
|
-
]);
|
|
64
|
-
return createEditor({ doc, extensions: starterExtensions() });
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* 에디터 어댑터 공통 DOM 계약 — 5개 렌더러가 같은 구조를 출력한다:
|
|
68
|
-
* .me-root > .me-editor[contenteditable][data-me-path=""]
|
|
69
|
-
* > [data-me-path="0"], [data-me-path="1"] … 블록 엘리먼트
|
|
70
|
-
* 트랜잭션 디스패치 → DOM 갱신, destroy → 정리까지 검증한다.
|
|
71
|
-
*/
|
|
72
|
-
export function runEditorConformance(label, mount) {
|
|
73
|
-
describe(`${label} 에디터 렌더 계약`, () => {
|
|
74
|
-
it("contenteditable 영역과 블록 엘리먼트를 렌더한다", async () => {
|
|
75
|
-
const editor = specEditor();
|
|
76
|
-
const { el, destroy } = await mount({ editor });
|
|
77
|
-
const editable = el.querySelector(".me-editor");
|
|
78
|
-
expect(editable).toBeTruthy();
|
|
79
|
-
expect(editable.getAttribute("contenteditable")).toBe("true");
|
|
80
|
-
expect(editable.getAttribute("data-me-path")).toBe("");
|
|
81
|
-
// 직계 자식만 — 텍스트 노드에도 data-me-path가 붙으므로
|
|
82
|
-
const blocks = editable.querySelectorAll(":scope > [data-me-path]");
|
|
83
|
-
expect(blocks.length).toBe(2);
|
|
84
|
-
expect(blocks[0].getAttribute("data-me-path")).toBe("0");
|
|
85
|
-
expect(blocks[0].textContent).toBe("안녕 굵게");
|
|
86
|
-
destroy();
|
|
87
|
-
});
|
|
88
|
-
it("bold 마크가 <strong>으로 렌더된다", async () => {
|
|
89
|
-
const editor = specEditor();
|
|
90
|
-
const { el, destroy } = await mount({ editor });
|
|
91
|
-
const strong = el.querySelector("[data-me-path='0,1']");
|
|
92
|
-
expect(strong?.tagName.toLowerCase()).toBe("strong");
|
|
93
|
-
expect(strong?.textContent).toBe(" 굵게");
|
|
94
|
-
destroy();
|
|
95
|
-
});
|
|
96
|
-
it("디스패치한 텍스트 삽입이 DOM에 반영된다", async () => {
|
|
97
|
-
const editor = specEditor();
|
|
98
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
99
|
-
editor.dispatch(editor.state
|
|
100
|
-
.tr()
|
|
101
|
-
.setSelection(textSelection(point([0, 0], 2)))
|
|
102
|
-
.insertText("!"));
|
|
103
|
-
await flush?.();
|
|
104
|
-
const para = el.querySelector("[data-me-path='0']");
|
|
105
|
-
expect(para.textContent).toBe("안녕! 굵게");
|
|
106
|
-
destroy();
|
|
107
|
-
});
|
|
108
|
-
it("캐럿의 최상위 블록에 활성 라인이 달린다 (Ace me-line-active)", async () => {
|
|
109
|
-
const editor = specEditor();
|
|
110
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
111
|
-
editor.dispatch(editor.state
|
|
112
|
-
.tr()
|
|
113
|
-
.setSelection(textSelection(point([1, 0], 0))));
|
|
114
|
-
await flush?.();
|
|
115
|
-
let active = el.querySelectorAll(".me-editor > .me-line-active");
|
|
116
|
-
expect(active.length).toBe(1);
|
|
117
|
-
expect(active[0].getAttribute("data-me-path")).toBe("1");
|
|
118
|
-
// 캐럿이 다른 블록으로 가면 이전 블록의 표시는 지워진다
|
|
119
|
-
editor.dispatch(editor.state
|
|
120
|
-
.tr()
|
|
121
|
-
.setSelection(textSelection(point([0, 0], 0))));
|
|
122
|
-
await flush?.();
|
|
123
|
-
active = el.querySelectorAll(".me-editor > .me-line-active");
|
|
124
|
-
expect(active.length).toBe(1);
|
|
125
|
-
expect(active[0].getAttribute("data-me-path")).toBe("0");
|
|
126
|
-
destroy();
|
|
127
|
-
});
|
|
128
|
-
it("Enter(split)로 블록이 둘로 나뉜다", async () => {
|
|
129
|
-
const editor = specEditor();
|
|
130
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
131
|
-
editor.dispatch(editor.state
|
|
132
|
-
.tr()
|
|
133
|
-
.setSelection(textSelection(point([0, 0], 1)))
|
|
134
|
-
.split());
|
|
135
|
-
await flush?.();
|
|
136
|
-
const blocks = el.querySelectorAll(".me-editor > [data-me-path]");
|
|
137
|
-
expect(blocks.length).toBe(3);
|
|
138
|
-
destroy();
|
|
139
|
-
});
|
|
140
|
-
it("빈 블록은 br 자리표시자로 렌더된다 (높이/캐럿 자리 확보)", async () => {
|
|
141
|
-
const editor = specEditor();
|
|
142
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
143
|
-
const doc = editor.state.doc;
|
|
144
|
-
const last = doc.content.length - 1;
|
|
145
|
-
// 문서 끝에 빈 문단 삽입 — 보이지 않는 빈 블록 회귀 계약
|
|
146
|
-
editor.dispatch(editor.state
|
|
147
|
-
.tr()
|
|
148
|
-
.insertNode({ path: [], offset: last + 1 }, { type: "paragraph", content: [] }));
|
|
149
|
-
await flush?.();
|
|
150
|
-
const empty = el.querySelector(`[data-me-path='${last + 1}']`);
|
|
151
|
-
expect(empty.querySelector("br")).not.toBeNull();
|
|
152
|
-
destroy();
|
|
153
|
-
});
|
|
154
|
-
it("HTML 붙여넣기는 sanitize+파싱되어 인라인으로 삽입된다", async () => {
|
|
155
|
-
const editor = specEditor();
|
|
156
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
157
|
-
const nodes = parseHTML(`<p><b>외부</b>텍스트<script>alert(1)</script><i>기울임</i></p>`, editor.state.schema);
|
|
158
|
-
editor.dispatch(editor.state
|
|
159
|
-
.tr()
|
|
160
|
-
.setSelection(textSelection(point([1, 0], 2)))
|
|
161
|
-
.insertSlice(point([1, 0], 2), nodes));
|
|
162
|
-
await flush?.();
|
|
163
|
-
const para = el.querySelector("[data-me-path='1']");
|
|
164
|
-
// 스크립트는 걸러지고 마크는 보존된다
|
|
165
|
-
expect(para.textContent).toBe("둘째외부텍스트기울임");
|
|
166
|
-
expect(para.querySelector("strong")?.textContent).toBe("외부");
|
|
167
|
-
expect(para.querySelector("em")?.textContent).toBe("기울임");
|
|
168
|
-
expect(para.querySelector("script")).toBeNull();
|
|
169
|
-
destroy();
|
|
170
|
-
});
|
|
171
|
-
it("undo/redo로 DOM이 되돌아간다", async () => {
|
|
172
|
-
const editor = specEditor();
|
|
173
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
174
|
-
const para = () => el.querySelector("[data-me-path='1']");
|
|
175
|
-
const before = para().textContent;
|
|
176
|
-
editor.dispatch(editor.state
|
|
177
|
-
.tr()
|
|
178
|
-
.setSelection(textSelection(point([1, 0], 2)))
|
|
179
|
-
.insertText("X"));
|
|
180
|
-
await flush?.();
|
|
181
|
-
expect(para().textContent).toBe("둘째X");
|
|
182
|
-
editor.commands.undo();
|
|
183
|
-
await flush?.();
|
|
184
|
-
expect(para().textContent).toBe(before);
|
|
185
|
-
editor.commands.redo();
|
|
186
|
-
await flush?.();
|
|
187
|
-
expect(para().textContent).toBe("둘째X");
|
|
188
|
-
destroy();
|
|
189
|
-
});
|
|
190
|
-
it("IME 조합 텍스트가 doc에 흡수된다", async () => {
|
|
191
|
-
const editor = specEditor();
|
|
192
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
193
|
-
const editable = el.querySelector(".me-editor");
|
|
194
|
-
const doc = el.ownerDocument;
|
|
195
|
-
const sel = doc.getSelection();
|
|
196
|
-
// 브라우저가 조합 중 하듯: compositionstart → DOM 직접 변경 →
|
|
197
|
-
// 캐럿 이동 → compositionend (조합 중 selectionchange는 무시된다)
|
|
198
|
-
editable.dispatchEvent(new Event("compositionstart"));
|
|
199
|
-
const holder = editable.querySelector("[data-me-path='1,0']");
|
|
200
|
-
// 렌더러마다 빈 텍스트/주석 노드가 섞일 수 있어 내용 있는
|
|
201
|
-
// 텍스트 노드를 찾는다
|
|
202
|
-
const walker = doc.createTreeWalker(holder, 4 /* NodeFilter.SHOW_TEXT */);
|
|
203
|
-
let text = null;
|
|
204
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
205
|
-
if (n.textContent)
|
|
206
|
-
text = n;
|
|
207
|
-
}
|
|
208
|
-
expect(text).toBeTruthy();
|
|
209
|
-
sel.setBaseAndExtent(text, 2, text, 2);
|
|
210
|
-
text.textContent = "둘째한글";
|
|
211
|
-
sel.setBaseAndExtent(text, 4, text, 4);
|
|
212
|
-
editable.dispatchEvent(new Event("compositionend"));
|
|
213
|
-
// 흡수는 다음 태스크 — 타이머 + 어댑터 플러시 대기
|
|
214
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
215
|
-
await flush?.();
|
|
216
|
-
expect(editor.state.doc.content[1]?.content?.[0]?.text).toBe("둘째한글");
|
|
217
|
-
expect(editable.querySelector("[data-me-path='1']").textContent).toBe("둘째한글");
|
|
218
|
-
destroy();
|
|
219
|
-
});
|
|
220
|
-
it("IME 커밋 Enter — 흡수 대기 중 insertParagraph는 doc 분할로 처리한다", async () => {
|
|
221
|
-
// Chromium은 조합 커밋 Enter에 compositionend 직후 insertParagraph
|
|
222
|
-
// beforeinput을 보낸다. composing 플래그가 아직 살아있는 이 창에
|
|
223
|
-
// 이 입력을 그냥 리턴하면 브라우저가 네이티브 split으로
|
|
224
|
-
// data-me-path가 복제된 블록을 만들고 doc과 영구히 어긋난다 —
|
|
225
|
-
// "Enter를 두 번 쳐야 내려간다" 회귀의 원인.
|
|
226
|
-
const editor = specEditor();
|
|
227
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
228
|
-
const editable = el.querySelector(".me-editor");
|
|
229
|
-
const doc = el.ownerDocument;
|
|
230
|
-
const sel = doc.getSelection();
|
|
231
|
-
editable.dispatchEvent(new Event("compositionstart"));
|
|
232
|
-
const holder = editable.querySelector("[data-me-path='1,0']");
|
|
233
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
234
|
-
let text = null;
|
|
235
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
236
|
-
if (n.textContent)
|
|
237
|
-
text = n;
|
|
238
|
-
}
|
|
239
|
-
text.textContent = "둘째한글";
|
|
240
|
-
sel.setBaseAndExtent(text, 4, text, 4);
|
|
241
|
-
editable.dispatchEvent(new Event("compositionend"));
|
|
242
|
-
// 흡수 타이머가 돌기 전 — 같은 태스크에 도착하는 커밋-Enter의
|
|
243
|
-
// insertParagraph를 시뮬레이션한다
|
|
244
|
-
const ev = new Event("beforeinput", {
|
|
245
|
-
bubbles: true,
|
|
246
|
-
cancelable: true,
|
|
247
|
-
});
|
|
248
|
-
Object.defineProperty(ev, "inputType", {
|
|
249
|
-
value: "insertParagraph",
|
|
250
|
-
});
|
|
251
|
-
Object.defineProperty(ev, "data", { value: null });
|
|
252
|
-
editable.dispatchEvent(ev);
|
|
253
|
-
await flush?.();
|
|
254
|
-
// 1) 커밋 텍스트가 doc에 흡수됐다
|
|
255
|
-
expect(editor.state.doc.content[1]?.content?.[0]?.text).toBe("둘째한글");
|
|
256
|
-
// 2) Enter가 한 번에 새 문단을 만들었다 — 커밋 끝점에서 분할
|
|
257
|
-
const para2 = editor.state.doc.content[2];
|
|
258
|
-
expect(para2?.type).toBe("paragraph");
|
|
259
|
-
expect((para2?.content ?? []).map((c) => c.text ?? "").join("")).toBe("");
|
|
260
|
-
// 3) 네이티브 split 흔적 — 복제된 data-me-path 블록이 없다
|
|
261
|
-
expect(editable.querySelectorAll("[data-me-path='2']").length).toBe(1);
|
|
262
|
-
destroy();
|
|
263
|
-
});
|
|
264
|
-
it("빈 블록의 IME — 브라우저가 br을 지워도 리렌더가 안정적이다", async () => {
|
|
265
|
-
const editor = specEditor();
|
|
266
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
267
|
-
const editable = el.querySelector(".me-editor");
|
|
268
|
-
const doc = el.ownerDocument;
|
|
269
|
-
// 빈 문단 — <br> 자리표시자가 들어간다
|
|
270
|
-
editor.dispatch(editor.state
|
|
271
|
-
.tr()
|
|
272
|
-
.insertNode({ path: [], offset: 2 }, { type: "paragraph", content: [createText("")] }));
|
|
273
|
-
await flush?.();
|
|
274
|
-
const holder = editable.querySelector("[data-me-path='2,0']");
|
|
275
|
-
// 실제 Chromium IME 재현 — 브라우저가 조합 중 <br>을 직접 지우고
|
|
276
|
-
// 조합 텍스트를 넣는다. vnode와 어긋난 DOM 위에서 프레임워크
|
|
277
|
-
// 리컨실이 removeChild 크래시로 터졌던 회귀 (React).
|
|
278
|
-
editable.dispatchEvent(new Event("compositionstart"));
|
|
279
|
-
const text = doc.createTextNode("글씨");
|
|
280
|
-
const br = holder.querySelector("br");
|
|
281
|
-
if (br)
|
|
282
|
-
holder.replaceChild(text, br);
|
|
283
|
-
else
|
|
284
|
-
holder.appendChild(text);
|
|
285
|
-
doc.getSelection().setBaseAndExtent(text, 2, text, 2);
|
|
286
|
-
editable.dispatchEvent(new Event("compositionend"));
|
|
287
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
288
|
-
await flush?.();
|
|
289
|
-
// 예외 없이 doc에 흡수 + DOM 일치까지 완료돼야 한다
|
|
290
|
-
expect(editor.state.doc.content[2]?.content?.[0]?.text).toBe("글씨");
|
|
291
|
-
expect(editable.querySelector("[data-me-path='2']").textContent).toBe("글씨");
|
|
292
|
-
destroy();
|
|
293
|
-
});
|
|
294
|
-
it("한 블록만 바뀌면 나머지 블록의 DOM 노드를 보존한다", async () => {
|
|
295
|
-
const editor = specEditor();
|
|
296
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
297
|
-
const p1 = el.querySelector("[data-me-path='1']");
|
|
298
|
-
editor.dispatch(editor.state
|
|
299
|
-
.tr()
|
|
300
|
-
.setSelection(textSelection(point([0, 0], 0)))
|
|
301
|
-
.insertText("X"));
|
|
302
|
-
await flush?.();
|
|
303
|
-
// 구조 공유 + 참조 메모이즈 — 미변경 블록의 DOM 엘리먼트 재사용
|
|
304
|
-
expect(el.querySelector("[data-me-path='1']")).toBe(p1);
|
|
305
|
-
expect(el.querySelector("[data-me-path='0']").textContent).toContain("X안녕");
|
|
306
|
-
destroy();
|
|
307
|
-
});
|
|
308
|
-
it("셀렉션만 바뀌는 디스패치는 DOM 노드를 보존한다", async () => {
|
|
309
|
-
const editor = specEditor();
|
|
310
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
311
|
-
const p0 = el.querySelector("[data-me-path='0']");
|
|
312
|
-
const p1 = el.querySelector("[data-me-path='1']");
|
|
313
|
-
editor.dispatch(editor.state.tr().setSelection(textSelection(point([1, 0], 1))));
|
|
314
|
-
await flush?.();
|
|
315
|
-
expect(el.querySelector("[data-me-path='0']")).toBe(p0);
|
|
316
|
-
expect(el.querySelector("[data-me-path='1']")).toBe(p1);
|
|
317
|
-
destroy();
|
|
318
|
-
});
|
|
319
|
-
it("커스텀 노드는 nodeView 컴포넌트로 렌더된다", async () => {
|
|
320
|
-
// testview 타입을 아는 스키마 + 해당 타입 블록이 있는 문서
|
|
321
|
-
const testview = {
|
|
322
|
-
name: "testview",
|
|
323
|
-
nodes: {
|
|
324
|
-
testview: {
|
|
325
|
-
group: "block",
|
|
326
|
-
atom: true,
|
|
327
|
-
attrs: { label: { default: "" } },
|
|
328
|
-
toDOM: () => ({ tag: "div" }),
|
|
329
|
-
},
|
|
330
|
-
},
|
|
331
|
-
};
|
|
332
|
-
const editor = createEditor({
|
|
333
|
-
doc: createDoc([
|
|
334
|
-
{ type: "paragraph", content: [createText("앞")] },
|
|
335
|
-
{ type: "testview", attrs: { label: "커스텀노드" } },
|
|
336
|
-
]),
|
|
337
|
-
extensions: [...starterExtensions(), testview],
|
|
338
|
-
});
|
|
339
|
-
const { el, destroy } = await mount({
|
|
340
|
-
editor,
|
|
341
|
-
nodeViews: { testview: true },
|
|
342
|
-
});
|
|
343
|
-
const wrap = el.querySelector(".me-node-view");
|
|
344
|
-
expect(wrap).toBeTruthy();
|
|
345
|
-
// 래퍼가 DOM 계약(data-me-path)을 유지한다
|
|
346
|
-
expect(wrap.getAttribute("data-me-path")).toBe("1");
|
|
347
|
-
expect(wrap.getAttribute("contenteditable")).toBe("false");
|
|
348
|
-
expect(wrap.querySelector(".me-custom-view")?.textContent).toContain("커스텀노드");
|
|
349
|
-
destroy();
|
|
350
|
-
});
|
|
351
|
-
it("콘텐츠 있는 노드 뷰는 .me-node-content 아울렛에 자식을 렌더한다", async () => {
|
|
352
|
-
// 비-atom 커스텀 블록 — 안쪽이 실제로 편집되는 영역이다.
|
|
353
|
-
// DOM 계약: .me-node-view(ce=false) > 크롬 >
|
|
354
|
-
// .me-node-content[data-me-path](ce=true) > AST 자식 1:1
|
|
355
|
-
const blockview = {
|
|
356
|
-
name: "blockview",
|
|
357
|
-
nodes: {
|
|
358
|
-
blockview: {
|
|
359
|
-
group: "block",
|
|
360
|
-
content: "block+",
|
|
361
|
-
toDOM: () => ({ tag: "div" }),
|
|
362
|
-
},
|
|
363
|
-
},
|
|
364
|
-
};
|
|
365
|
-
const editor = createEditor({
|
|
366
|
-
doc: createDoc([
|
|
367
|
-
{ type: "paragraph", content: [createText("앞")] },
|
|
368
|
-
{
|
|
369
|
-
type: "blockview",
|
|
370
|
-
content: [
|
|
371
|
-
{ type: "paragraph", content: [createText("안쪽")] },
|
|
372
|
-
],
|
|
373
|
-
},
|
|
374
|
-
]),
|
|
375
|
-
extensions: [...starterExtensions(), blockview],
|
|
376
|
-
});
|
|
377
|
-
const { el, flush, destroy } = await mount({
|
|
378
|
-
editor,
|
|
379
|
-
nodeViews: { blockview: true },
|
|
380
|
-
});
|
|
381
|
-
const wrap = el.querySelector(".me-node-view");
|
|
382
|
-
expect(wrap).toBeTruthy();
|
|
383
|
-
expect(wrap.getAttribute("contenteditable")).toBe("false");
|
|
384
|
-
// 래퍼는 data-me-path를 갖지 않는다 — 아울렛이 갖는다
|
|
385
|
-
expect(wrap.getAttribute("data-me-path")).toBeNull();
|
|
386
|
-
const content = wrap.querySelector(".me-node-content");
|
|
387
|
-
expect(content).toBeTruthy();
|
|
388
|
-
expect(content.getAttribute("data-me-path")).toBe("1");
|
|
389
|
-
expect(content.getAttribute("contenteditable")).toBe("true");
|
|
390
|
-
// AST 자식이 아울렛 직계 자식과 1:1
|
|
391
|
-
expect(content.children.length).toBe(1);
|
|
392
|
-
expect(content.querySelector("[data-me-path='1,0']").textContent).toBe("안쪽");
|
|
393
|
-
// 안쪽 자식에 디스패치한 편집이 DOM에 반영된다 — 렌더러마다 아울렛이
|
|
394
|
-
// 통째 교체될 수 있어 리렌더 후 재질의한다
|
|
395
|
-
editor.dispatch(editor.state
|
|
396
|
-
.tr()
|
|
397
|
-
.setSelection(textSelection(point([1, 0, 0], 2)))
|
|
398
|
-
.insertText("!"));
|
|
399
|
-
await flush?.();
|
|
400
|
-
expect(el.querySelector(".me-node-content [data-me-path='1,0']")
|
|
401
|
-
.textContent).toBe("안쪽!");
|
|
402
|
-
destroy();
|
|
403
|
-
});
|
|
404
|
-
it("노드 뷰 아울렛은 toDOM 태그/속성을 쓴다 — codeBlock이면 pre[data-language]", async () => {
|
|
405
|
-
// 코드블록에 nodeView가 붙어도 pre[data-me-path]·data-language
|
|
406
|
-
// 계약이 유지돼야 구문 하이라이트·셀렉터가 그대로 동작한다
|
|
407
|
-
const editor = createEditor({
|
|
408
|
-
doc: createDoc([
|
|
409
|
-
{ type: "paragraph", content: [createText("앞")] },
|
|
410
|
-
{
|
|
411
|
-
type: "codeBlock",
|
|
412
|
-
attrs: { language: "js" },
|
|
413
|
-
content: [createText("const a = 1")],
|
|
414
|
-
},
|
|
415
|
-
]),
|
|
416
|
-
extensions: starterExtensions(),
|
|
417
|
-
});
|
|
418
|
-
const { el, destroy } = await mount({
|
|
419
|
-
editor,
|
|
420
|
-
nodeViews: { codeBlock: true },
|
|
421
|
-
});
|
|
422
|
-
const outlet = el.querySelector(".me-node-content");
|
|
423
|
-
expect(outlet).toBeTruthy();
|
|
424
|
-
expect(outlet.tagName).toBe("PRE");
|
|
425
|
-
expect(outlet.getAttribute("data-me-path")).toBe("1");
|
|
426
|
-
expect(outlet.getAttribute("data-language")).toBe("js");
|
|
427
|
-
expect(outlet.getAttribute("contenteditable")).toBe("true");
|
|
428
|
-
destroy();
|
|
429
|
-
});
|
|
430
|
-
it("마크다운 입력 규칙 — `## `는 제목으로, `**x**`는 굵게로 변환된다", async () => {
|
|
431
|
-
// beforeinput → insertText → applyInputRules의 전체 배선을 검증한다.
|
|
432
|
-
// happy-dom에는 InputEvent가 없어 프로퍼티를 직접 박은 Event로 대신한다
|
|
433
|
-
const type = async (el, text, data) => {
|
|
434
|
-
const doc = el.ownerDocument;
|
|
435
|
-
const editable = el.querySelector(".me-editor");
|
|
436
|
-
const holder = editable.querySelector("[data-me-path='0,0']");
|
|
437
|
-
const walker = doc.createTreeWalker(holder, 4 /* SHOW_TEXT */);
|
|
438
|
-
let t = null;
|
|
439
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
440
|
-
if (n.textContent === text)
|
|
441
|
-
t = n;
|
|
442
|
-
}
|
|
443
|
-
doc.getSelection().setBaseAndExtent(t, text.length, t, text.length);
|
|
444
|
-
const ev = new Event("beforeinput", {
|
|
445
|
-
bubbles: true,
|
|
446
|
-
cancelable: true,
|
|
447
|
-
});
|
|
448
|
-
Object.defineProperties(ev, {
|
|
449
|
-
inputType: { value: "insertText" },
|
|
450
|
-
data: { value: data },
|
|
451
|
-
});
|
|
452
|
-
editable.dispatchEvent(ev);
|
|
453
|
-
};
|
|
454
|
-
const editor = createEditor({
|
|
455
|
-
doc: createDoc([
|
|
456
|
-
{ type: "paragraph", content: [createText("##")] },
|
|
457
|
-
]),
|
|
458
|
-
extensions: starterExtensions(),
|
|
459
|
-
});
|
|
460
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
461
|
-
await type(el, "##", " ");
|
|
462
|
-
await flush?.();
|
|
463
|
-
const h2 = el.querySelector(".me-editor > [data-me-path='0']");
|
|
464
|
-
expect(h2.tagName.toLowerCase()).toBe("h2");
|
|
465
|
-
expect(h2.textContent).toBe("");
|
|
466
|
-
destroy();
|
|
467
|
-
const editor2 = createEditor({
|
|
468
|
-
doc: createDoc([
|
|
469
|
-
{ type: "paragraph", content: [createText("**x*")] },
|
|
470
|
-
]),
|
|
471
|
-
extensions: starterExtensions(),
|
|
472
|
-
});
|
|
473
|
-
const m2 = await mount({ editor: editor2 });
|
|
474
|
-
await type(m2.el, "**x*", "*");
|
|
475
|
-
await m2.flush?.();
|
|
476
|
-
const strong = m2.el.querySelector(".me-editor > [data-me-path='0'] strong");
|
|
477
|
-
expect(strong?.textContent).toBe("x");
|
|
478
|
-
m2.destroy();
|
|
479
|
-
});
|
|
480
|
-
it("스타터 블록 스키마를 렌더한다 — 목록/인용구/코드/hr/br/링크", async () => {
|
|
481
|
-
const editor = createEditor({
|
|
482
|
-
doc: createDoc([
|
|
483
|
-
{
|
|
484
|
-
type: "bulletList",
|
|
485
|
-
content: [
|
|
486
|
-
{
|
|
487
|
-
type: "listItem",
|
|
488
|
-
content: [
|
|
489
|
-
{ type: "paragraph", content: [createText("가")] },
|
|
490
|
-
],
|
|
491
|
-
},
|
|
492
|
-
],
|
|
493
|
-
},
|
|
494
|
-
{
|
|
495
|
-
type: "orderedList",
|
|
496
|
-
attrs: { start: 3 },
|
|
497
|
-
content: [
|
|
498
|
-
{
|
|
499
|
-
type: "listItem",
|
|
500
|
-
content: [
|
|
501
|
-
{ type: "paragraph", content: [createText("나")] },
|
|
502
|
-
],
|
|
503
|
-
},
|
|
504
|
-
],
|
|
505
|
-
},
|
|
506
|
-
{
|
|
507
|
-
type: "blockquote",
|
|
508
|
-
content: [{ type: "paragraph", content: [createText("다")] }],
|
|
509
|
-
},
|
|
510
|
-
{ type: "codeBlock", content: [createText("라인1\n라인2")] },
|
|
511
|
-
{ type: "horizontalRule" },
|
|
512
|
-
{
|
|
513
|
-
type: "paragraph",
|
|
514
|
-
content: [
|
|
515
|
-
{
|
|
516
|
-
type: "text",
|
|
517
|
-
text: "링크",
|
|
518
|
-
marks: [{ type: "link", attrs: { href: "https://a.b" } }],
|
|
519
|
-
},
|
|
520
|
-
],
|
|
521
|
-
},
|
|
522
|
-
{
|
|
523
|
-
type: "paragraph",
|
|
524
|
-
content: [
|
|
525
|
-
createText("윗줄"),
|
|
526
|
-
{ type: "hardBreak" },
|
|
527
|
-
createText("아랫줄"),
|
|
528
|
-
],
|
|
529
|
-
},
|
|
530
|
-
]),
|
|
531
|
-
extensions: starterExtensions(),
|
|
532
|
-
});
|
|
533
|
-
const { el, destroy } = await mount({ editor });
|
|
534
|
-
expect(el.querySelector("ul[data-me-path='0'] > li > p")?.textContent).toBe("가");
|
|
535
|
-
const ol = el.querySelector("ol[data-me-path='1']");
|
|
536
|
-
expect(ol.querySelector("li > p")?.textContent).toBe("나");
|
|
537
|
-
expect(ol.getAttribute("start")).toBe("3");
|
|
538
|
-
expect(el.querySelector("blockquote[data-me-path='2'] > p")?.textContent).toBe("다");
|
|
539
|
-
expect(el.querySelector("pre[data-me-path='3']")?.textContent).toBe("라인1\n라인2");
|
|
540
|
-
expect(el.querySelector("hr[data-me-path='4']")).toBeTruthy();
|
|
541
|
-
expect(el.querySelector("a[href='https://a.b']")?.textContent).toBe("링크");
|
|
542
|
-
// 하드 브레이크는 data-me-path를 갖는 br — bogus br과 구분된다
|
|
543
|
-
expect(el.querySelector("br[data-me-path='6,1']")).toBeTruthy();
|
|
544
|
-
destroy();
|
|
545
|
-
});
|
|
546
|
-
it("래핑 입력 규칙 — `- `는 ul, `> `는 blockquote, ``` 는 pre", async () => {
|
|
547
|
-
// beforeinput 경로 — 마크 규칙 계약과 같은 시뮬레이션
|
|
548
|
-
const type = async (el, text, data) => {
|
|
549
|
-
const doc = el.ownerDocument;
|
|
550
|
-
const editable = el.querySelector(".me-editor");
|
|
551
|
-
const holder = editable.querySelector("[data-me-path='0,0']");
|
|
552
|
-
const walker = doc.createTreeWalker(holder, 4 /* SHOW_TEXT */);
|
|
553
|
-
let t = null;
|
|
554
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
555
|
-
if (n.textContent === text)
|
|
556
|
-
t = n;
|
|
557
|
-
}
|
|
558
|
-
doc
|
|
559
|
-
.getSelection()
|
|
560
|
-
.setBaseAndExtent(t, text.length, t, text.length);
|
|
561
|
-
const ev = new Event("beforeinput", {
|
|
562
|
-
bubbles: true,
|
|
563
|
-
cancelable: true,
|
|
564
|
-
});
|
|
565
|
-
Object.defineProperties(ev, {
|
|
566
|
-
inputType: { value: "insertText" },
|
|
567
|
-
data: { value: data },
|
|
568
|
-
});
|
|
569
|
-
editable.dispatchEvent(ev);
|
|
570
|
-
};
|
|
571
|
-
const cases = [
|
|
572
|
-
["-", "ul[data-me-path='0'] > li > p"],
|
|
573
|
-
[">", "blockquote[data-me-path='0'] > p"],
|
|
574
|
-
];
|
|
575
|
-
for (const [typed, sel] of cases) {
|
|
576
|
-
const editor = createEditor({
|
|
577
|
-
doc: createDoc([
|
|
578
|
-
{ type: "paragraph", content: [createText(typed)] },
|
|
579
|
-
]),
|
|
580
|
-
extensions: starterExtensions(),
|
|
581
|
-
});
|
|
582
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
583
|
-
await type(el, typed, " ");
|
|
584
|
-
await flush?.();
|
|
585
|
-
expect(el.querySelector(sel)).toBeTruthy();
|
|
586
|
-
destroy();
|
|
587
|
-
}
|
|
588
|
-
// ``` + 스페이스 → pre (Tiptap 관례 — 공백이 트리거)
|
|
589
|
-
const editor = createEditor({
|
|
590
|
-
doc: createDoc([
|
|
591
|
-
{ type: "paragraph", content: [createText("```")] },
|
|
592
|
-
]),
|
|
593
|
-
extensions: starterExtensions(),
|
|
594
|
-
});
|
|
595
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
596
|
-
await type(el, "```", " ");
|
|
597
|
-
await flush?.();
|
|
598
|
-
expect(el.querySelector("pre[data-me-path='0']")).toBeTruthy();
|
|
599
|
-
destroy();
|
|
600
|
-
});
|
|
601
|
-
it("리스트 아이템 Enter — 새 항목, 빈 항목 Enter — 리스트 탈출", async () => {
|
|
602
|
-
const fireEnter = async (el, holderSel) => {
|
|
603
|
-
const doc = el.ownerDocument;
|
|
604
|
-
const editable = el.querySelector(".me-editor");
|
|
605
|
-
const holder = editable.querySelector(holderSel);
|
|
606
|
-
// 내용 있는 텍스트 노드가 있으면 그 끝, 없으면(빈 문단) 홀더 앞
|
|
607
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
608
|
-
let t = null;
|
|
609
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
610
|
-
if (n.textContent)
|
|
611
|
-
t = n;
|
|
612
|
-
}
|
|
613
|
-
const sel = doc.getSelection();
|
|
614
|
-
if (t)
|
|
615
|
-
sel.setBaseAndExtent(t, t.textContent.length, t, t.textContent.length);
|
|
616
|
-
else
|
|
617
|
-
sel.setBaseAndExtent(holder, 0, holder, 0);
|
|
618
|
-
const ev = new Event("beforeinput", {
|
|
619
|
-
bubbles: true,
|
|
620
|
-
cancelable: true,
|
|
621
|
-
});
|
|
622
|
-
Object.defineProperty(ev, "inputType", {
|
|
623
|
-
value: "insertParagraph",
|
|
624
|
-
});
|
|
625
|
-
editable.dispatchEvent(ev);
|
|
626
|
-
};
|
|
627
|
-
const mk = () => createEditor({
|
|
628
|
-
doc: createDoc([
|
|
629
|
-
{
|
|
630
|
-
type: "bulletList",
|
|
631
|
-
content: [
|
|
632
|
-
{
|
|
633
|
-
type: "listItem",
|
|
634
|
-
content: [
|
|
635
|
-
{ type: "paragraph", content: [createText("가")] },
|
|
636
|
-
],
|
|
637
|
-
},
|
|
638
|
-
],
|
|
639
|
-
},
|
|
640
|
-
]),
|
|
641
|
-
extensions: starterExtensions(),
|
|
642
|
-
});
|
|
643
|
-
// 아이템 끝 Enter → 같은 리스트에 새 아이템
|
|
644
|
-
{
|
|
645
|
-
const editor = mk();
|
|
646
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
647
|
-
await fireEnter(el, "[data-me-path='0,0,0,0']");
|
|
648
|
-
await flush?.();
|
|
649
|
-
expect(el.querySelectorAll("ul > li").length).toBe(2);
|
|
650
|
-
destroy();
|
|
651
|
-
}
|
|
652
|
-
// 빈 아이템 Enter → 아이템이 리스트 밖 문단으로 빠진다
|
|
653
|
-
{
|
|
654
|
-
const editor = createEditor({
|
|
655
|
-
doc: createDoc([
|
|
656
|
-
{
|
|
657
|
-
type: "bulletList",
|
|
658
|
-
content: [
|
|
659
|
-
{
|
|
660
|
-
type: "listItem",
|
|
661
|
-
content: [
|
|
662
|
-
{ type: "paragraph", content: [createText("")] },
|
|
663
|
-
],
|
|
664
|
-
},
|
|
665
|
-
],
|
|
666
|
-
},
|
|
667
|
-
]),
|
|
668
|
-
extensions: starterExtensions(),
|
|
669
|
-
});
|
|
670
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
671
|
-
await fireEnter(el, "[data-me-path='0,0,0,0']");
|
|
672
|
-
await flush?.();
|
|
673
|
-
expect(el.querySelector("ul")).toBeNull();
|
|
674
|
-
expect(el.querySelector(".me-editor > p[data-me-path='0']")).toBeTruthy();
|
|
675
|
-
destroy();
|
|
676
|
-
}
|
|
677
|
-
});
|
|
678
|
-
it("Shift-Enter는 하드 브레이크를 삽입한다", async () => {
|
|
679
|
-
const editor = createEditor({
|
|
680
|
-
doc: createDoc([
|
|
681
|
-
{ type: "paragraph", content: [createText("ab")] },
|
|
682
|
-
]),
|
|
683
|
-
extensions: starterExtensions(),
|
|
684
|
-
});
|
|
685
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
686
|
-
const editable = el.querySelector(".me-editor");
|
|
687
|
-
const doc = el.ownerDocument;
|
|
688
|
-
const holder = editable.querySelector("[data-me-path='0,0']");
|
|
689
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
690
|
-
let t = null;
|
|
691
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
692
|
-
if (n.textContent === "ab")
|
|
693
|
-
t = n;
|
|
694
|
-
}
|
|
695
|
-
doc.getSelection().setBaseAndExtent(t, 1, t, 1);
|
|
696
|
-
editable.dispatchEvent(new KeyboardEvent("keydown", {
|
|
697
|
-
key: "Enter",
|
|
698
|
-
shiftKey: true,
|
|
699
|
-
bubbles: true,
|
|
700
|
-
cancelable: true,
|
|
701
|
-
}));
|
|
702
|
-
await flush?.();
|
|
703
|
-
expect(el.querySelector("br[data-me-path='0,1']")).toBeTruthy();
|
|
704
|
-
const kids = editor.state.doc.content[0].content;
|
|
705
|
-
expect(kids.map((k) => k.type)).toEqual([
|
|
706
|
-
"text",
|
|
707
|
-
"hardBreak",
|
|
708
|
-
"text",
|
|
709
|
-
]);
|
|
710
|
-
destroy();
|
|
711
|
-
});
|
|
712
|
-
it("Mod-a는 doc 전체 선택을 만든다 — 중첩 editable이 있어도", async () => {
|
|
713
|
-
// Chromium은 ce=false 래퍼 안의 editable 아울렛(노드뷰)을 만나면
|
|
714
|
-
// 네이티브 select-all이 셀렉션을 collapse시킨다 — keydown에서
|
|
715
|
-
// 가로채 doc 전체 선택을 직접 디스패치한다
|
|
716
|
-
const editor = specEditor();
|
|
717
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
718
|
-
const editable = el.querySelector(".me-editor");
|
|
719
|
-
const ev = new KeyboardEvent("keydown", {
|
|
720
|
-
key: "a",
|
|
721
|
-
metaKey: true,
|
|
722
|
-
bubbles: true,
|
|
723
|
-
cancelable: true,
|
|
724
|
-
});
|
|
725
|
-
editable.dispatchEvent(ev);
|
|
726
|
-
await flush?.();
|
|
727
|
-
expect(ev.defaultPrevented).toBe(true);
|
|
728
|
-
// DOM 복원 후 selectionchange가 리프 좌표로 재동기화한다 —
|
|
729
|
-
// happy-dom은 Selection 구현이 제한적이라 끝점 오프셋이
|
|
730
|
-
// 클램프될 수 있어, 앵커=문서 시작 + 비축소 범위를 본다.
|
|
731
|
-
// DOM 셀렉션 확장 자체는 e2e(감사 — Mod-A)가 검증한다
|
|
732
|
-
const sel = editor.state.selection;
|
|
733
|
-
expect(sel.type).toBe("text");
|
|
734
|
-
if (sel.type === "text") {
|
|
735
|
-
const start = leafPoint(editor.state.doc, {
|
|
736
|
-
path: [],
|
|
737
|
-
offset: 0,
|
|
738
|
-
});
|
|
739
|
-
expect(leafPoint(editor.state.doc, sel.anchor)).toEqual(start);
|
|
740
|
-
expect(pointEq(sel.anchor, sel.focus)).toBe(false);
|
|
741
|
-
}
|
|
742
|
-
destroy();
|
|
743
|
-
});
|
|
744
|
-
it("전체 선택 삭제 후 타이핑은 단일 문단이 된다", async () => {
|
|
745
|
-
// Cmd+A → 타이핑과 같은 경로 — doc 루트 경계 포인트에
|
|
746
|
-
// bare text가 들어가는 회귀 방지
|
|
747
|
-
const editor = specEditor();
|
|
748
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
749
|
-
editor.dispatch(editor.state
|
|
750
|
-
.tr()
|
|
751
|
-
.delete({ anchor: point([], 0), focus: point([], 2) })
|
|
752
|
-
.insertText("Z", point([], 0)));
|
|
753
|
-
await flush?.();
|
|
754
|
-
const blocks = el.querySelectorAll(".me-editor > [data-me-path]");
|
|
755
|
-
expect(blocks.length).toBe(1);
|
|
756
|
-
expect(blocks[0].tagName.toLowerCase()).toBe("p");
|
|
757
|
-
expect(blocks[0].textContent).toBe("Z");
|
|
758
|
-
expect(editor.state.doc.content[0].type).toBe("paragraph");
|
|
759
|
-
destroy();
|
|
760
|
-
});
|
|
761
|
-
it("확장 스키마 렌더 — 표/이미지/할일/정렬/첨자/색상 마크", async () => {
|
|
762
|
-
const editor = createEditor({
|
|
763
|
-
doc: createDoc([
|
|
764
|
-
{
|
|
765
|
-
type: "table",
|
|
766
|
-
content: [
|
|
767
|
-
{
|
|
768
|
-
type: "tableRow",
|
|
769
|
-
content: [
|
|
770
|
-
{
|
|
771
|
-
type: "tableCell",
|
|
772
|
-
content: [
|
|
773
|
-
{ type: "paragraph", content: [createText("셀")] },
|
|
774
|
-
],
|
|
775
|
-
},
|
|
776
|
-
],
|
|
777
|
-
},
|
|
778
|
-
],
|
|
779
|
-
},
|
|
780
|
-
{
|
|
781
|
-
type: "image",
|
|
782
|
-
attrs: { src: "https://x.y/a.png", alt: "이미지" },
|
|
783
|
-
},
|
|
784
|
-
{
|
|
785
|
-
type: "taskList",
|
|
786
|
-
content: [
|
|
787
|
-
{
|
|
788
|
-
type: "taskItem",
|
|
789
|
-
attrs: { checked: true },
|
|
790
|
-
content: [
|
|
791
|
-
{ type: "paragraph", content: [createText("할일")] },
|
|
792
|
-
],
|
|
793
|
-
},
|
|
794
|
-
],
|
|
795
|
-
},
|
|
796
|
-
{
|
|
797
|
-
type: "paragraph",
|
|
798
|
-
attrs: { align: "center" },
|
|
799
|
-
content: [
|
|
800
|
-
{
|
|
801
|
-
type: "text",
|
|
802
|
-
text: "첨자",
|
|
803
|
-
marks: [{ type: "superscript" }],
|
|
804
|
-
},
|
|
805
|
-
{
|
|
806
|
-
type: "text",
|
|
807
|
-
text: "색",
|
|
808
|
-
marks: [{ type: "textColor", attrs: { color: "#f00" } }],
|
|
809
|
-
},
|
|
810
|
-
],
|
|
811
|
-
},
|
|
812
|
-
{
|
|
813
|
-
type: "codeBlock",
|
|
814
|
-
attrs: { language: "ts" },
|
|
815
|
-
content: [createText("const x = 1")],
|
|
816
|
-
},
|
|
817
|
-
]),
|
|
818
|
-
extensions: starterExtensions(),
|
|
819
|
-
});
|
|
820
|
-
const { el, destroy } = await mount({ editor });
|
|
821
|
-
expect(el.querySelector("table.me-table[data-me-path='0'] > tr > td > p")?.textContent).toBe("셀");
|
|
822
|
-
const img = el.querySelector("img[data-me-path='1']");
|
|
823
|
-
expect(img).toBeTruthy();
|
|
824
|
-
expect(img.getAttribute("src")).toBe("https://x.y/a.png");
|
|
825
|
-
const task = el.querySelector("ul.me-tasks[data-me-path='2'] > li.me-task");
|
|
826
|
-
expect(task).toBeTruthy();
|
|
827
|
-
expect(task.getAttribute("data-checked")).toBe("true");
|
|
828
|
-
const para = el.querySelector("p[data-me-path='3']");
|
|
829
|
-
expect(para.getAttribute("style")).toContain("text-align: center");
|
|
830
|
-
expect(para.querySelector("sup")?.textContent).toBe("첨자");
|
|
831
|
-
const colored = para.querySelector("span[style*='color']");
|
|
832
|
-
expect(colored?.textContent).toBe("색");
|
|
833
|
-
const pre = el.querySelector("pre[data-me-path='4']");
|
|
834
|
-
expect(pre?.getAttribute("data-language")).toBe("ts");
|
|
835
|
-
// 구문 하이라이트 — 텍스트가 .me-tok-* 토큰 스팬으로 쪼개진다.
|
|
836
|
-
// doc은 오염되지 않는다: textContent는 원문 그대로 (뷰 데코레이션)
|
|
837
|
-
expect(pre?.querySelector(".me-tok-keyword")?.textContent).toBe("const");
|
|
838
|
-
expect(pre?.textContent).toBe("const x = 1");
|
|
839
|
-
// 줄 번호 거터 — 토큰이 줄 단위 .me-cl로 묶인다 (한 줄이면 1개)
|
|
840
|
-
expect(pre?.querySelectorAll(".me-cl").length).toBe(1);
|
|
841
|
-
destroy();
|
|
842
|
-
});
|
|
843
|
-
it("코드블록 Tab/Shift-Tab — 줄 단위 들여쓰기/내어쓰기", async () => {
|
|
844
|
-
const editor = createEditor({
|
|
845
|
-
doc: createDoc([
|
|
846
|
-
{
|
|
847
|
-
type: "codeBlock",
|
|
848
|
-
content: [createText("one\ntwo")],
|
|
849
|
-
},
|
|
850
|
-
]),
|
|
851
|
-
extensions: starterExtensions(),
|
|
852
|
-
});
|
|
853
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
854
|
-
const doc = el.ownerDocument;
|
|
855
|
-
const editable = el.querySelector(".me-editor");
|
|
856
|
-
// 캐럿을 두 번째 줄(two)에 놓는다 — 하이라이트 토큰 스팬 안의
|
|
857
|
-
// 실제 텍스트 노드로 내려간다 (매핑은 텍스트 누적이라 안전)
|
|
858
|
-
const holder = el.querySelector("pre[data-me-path='0'] > [data-me-path='0,0']");
|
|
859
|
-
const walker = doc.createTreeWalker(holder, 4 /* SHOW_TEXT */);
|
|
860
|
-
// 줄 구조 — "one\ntwo"는 2개 .me-cl, 개행은 줄 끝 텍스트로 보존
|
|
861
|
-
expect(el.querySelectorAll("pre .me-cl").length).toBe(2);
|
|
862
|
-
// 두 번째 줄(two)의 텍스트 노드를 찾아 전체를 선택한다
|
|
863
|
-
let text = null;
|
|
864
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
865
|
-
if (n.textContent === "two") {
|
|
866
|
-
text = n;
|
|
867
|
-
break;
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
doc.getSelection().setBaseAndExtent(text, 0, text, 3);
|
|
871
|
-
doc.dispatchEvent(new Event("selectionchange"));
|
|
872
|
-
await flush?.();
|
|
873
|
-
editable.dispatchEvent(new KeyboardEvent("keydown", {
|
|
874
|
-
key: "Tab",
|
|
875
|
-
bubbles: true,
|
|
876
|
-
cancelable: true,
|
|
877
|
-
}));
|
|
878
|
-
await flush?.();
|
|
879
|
-
expect(el.querySelector("pre").textContent).toBe("one\n\ttwo");
|
|
880
|
-
editable.dispatchEvent(new KeyboardEvent("keydown", {
|
|
881
|
-
key: "Tab",
|
|
882
|
-
shiftKey: true,
|
|
883
|
-
bubbles: true,
|
|
884
|
-
cancelable: true,
|
|
885
|
-
}));
|
|
886
|
-
await flush?.();
|
|
887
|
-
expect(el.querySelector("pre").textContent).toBe("one\ntwo");
|
|
888
|
-
destroy();
|
|
889
|
-
});
|
|
890
|
-
it("코드블록 Enter — 자동 들여쓰기(Ace) + 공백 꼬리 Enter 탈출", async () => {
|
|
891
|
-
const editor = createEditor({
|
|
892
|
-
doc: createDoc([
|
|
893
|
-
{
|
|
894
|
-
type: "codeBlock",
|
|
895
|
-
content: [createText(" const a = 1")],
|
|
896
|
-
},
|
|
897
|
-
]),
|
|
898
|
-
extensions: starterExtensions(),
|
|
899
|
-
});
|
|
900
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
901
|
-
const doc = el.ownerDocument;
|
|
902
|
-
const editable = el.querySelector(".me-editor");
|
|
903
|
-
const fireEnter = () => {
|
|
904
|
-
const holder = editable.querySelector("pre[data-me-path='0'] [data-me-path='0,0']");
|
|
905
|
-
// 마지막 텍스트 노드 끝에 캐럿
|
|
906
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
907
|
-
let t = null;
|
|
908
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
909
|
-
if (n.textContent)
|
|
910
|
-
t = n;
|
|
911
|
-
}
|
|
912
|
-
const sel = doc.getSelection();
|
|
913
|
-
sel.setBaseAndExtent(t, t.textContent.length, t, t.textContent.length);
|
|
914
|
-
const ev = new Event("beforeinput", {
|
|
915
|
-
bubbles: true,
|
|
916
|
-
cancelable: true,
|
|
917
|
-
});
|
|
918
|
-
Object.defineProperty(ev, "inputType", {
|
|
919
|
-
value: "insertParagraph",
|
|
920
|
-
});
|
|
921
|
-
editable.dispatchEvent(ev);
|
|
922
|
-
};
|
|
923
|
-
fireEnter();
|
|
924
|
-
await flush?.();
|
|
925
|
-
// 들여쓰기 복사 — 새 줄이 같은 선행 공백으로 시작 (Ace 관례)
|
|
926
|
-
expect(el.querySelector("pre").textContent).toBe(" const a = 1\n ");
|
|
927
|
-
// 공백뿐인 꼬리 줄에서 Enter — 꼬리를 지우고 본문으로 탈출
|
|
928
|
-
fireEnter();
|
|
929
|
-
await flush?.();
|
|
930
|
-
expect(el.querySelector("pre").textContent).toBe(" const a = 1");
|
|
931
|
-
expect(el.querySelector("p[data-me-path='1']")).toBeTruthy();
|
|
932
|
-
destroy();
|
|
933
|
-
});
|
|
934
|
-
it("코드블록 자동 쌍·타입오버·쌍 삭제 (Ace behaviours 관례)", async () => {
|
|
935
|
-
const editor = createEditor({
|
|
936
|
-
doc: createDoc([
|
|
937
|
-
{ type: "codeBlock", content: [createText("x")] },
|
|
938
|
-
]),
|
|
939
|
-
extensions: starterExtensions(),
|
|
940
|
-
});
|
|
941
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
942
|
-
const doc = el.ownerDocument;
|
|
943
|
-
const editable = el.querySelector(".me-editor");
|
|
944
|
-
const fire = (inputType, data) => {
|
|
945
|
-
const ev = new Event("beforeinput", {
|
|
946
|
-
bubbles: true,
|
|
947
|
-
cancelable: true,
|
|
948
|
-
});
|
|
949
|
-
Object.defineProperties(ev, {
|
|
950
|
-
inputType: { value: inputType },
|
|
951
|
-
data: { value: data },
|
|
952
|
-
});
|
|
953
|
-
editable.dispatchEvent(ev);
|
|
954
|
-
};
|
|
955
|
-
// 캐럿을 첫 텍스트 노드 오프셋에 놓는다
|
|
956
|
-
const caretAt = (off) => {
|
|
957
|
-
const holder = editable.querySelector("pre[data-me-path='0'] [data-me-path='0,0']");
|
|
958
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
959
|
-
let acc = 0;
|
|
960
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
961
|
-
const len = n.textContent?.length ?? 0;
|
|
962
|
-
if (off <= acc + len) {
|
|
963
|
-
doc.getSelection().setBaseAndExtent(n, off - acc, n, off - acc);
|
|
964
|
-
return;
|
|
965
|
-
}
|
|
966
|
-
acc += len;
|
|
967
|
-
}
|
|
968
|
-
};
|
|
969
|
-
// "(" → "()" — 캐럿은 쌍 안쪽
|
|
970
|
-
caretAt(1);
|
|
971
|
-
fire("insertText", "(");
|
|
972
|
-
await flush?.();
|
|
973
|
-
expect(el.querySelector("pre").textContent).toBe("x()");
|
|
974
|
-
let sel = editor.state.selection;
|
|
975
|
-
if (sel.type === "text")
|
|
976
|
-
expect(sel.anchor.offset).toBe(2);
|
|
977
|
-
// ")" 타입오버 — 같은 닫기가 뒤에 있으면 넘어간다
|
|
978
|
-
caretAt(2);
|
|
979
|
-
fire("insertText", ")");
|
|
980
|
-
await flush?.();
|
|
981
|
-
expect(el.querySelector("pre").textContent).toBe("x()");
|
|
982
|
-
sel = editor.state.selection;
|
|
983
|
-
if (sel.type === "text")
|
|
984
|
-
expect(sel.anchor.offset).toBe(3);
|
|
985
|
-
// 빈 쌍 사이 Backspace — 쌍을 통째로 지운다
|
|
986
|
-
caretAt(2);
|
|
987
|
-
fire("deleteContentBackward");
|
|
988
|
-
await flush?.();
|
|
989
|
-
expect(el.querySelector("pre").textContent).toBe("x");
|
|
990
|
-
destroy();
|
|
991
|
-
});
|
|
992
|
-
it("코드블록 자동 쌍 — 문자열/주석 토큰 안에서는 리터럴 (Ace isCodeLike)", async () => {
|
|
993
|
-
const editor = createEditor({
|
|
994
|
-
doc: createDoc([
|
|
995
|
-
{
|
|
996
|
-
type: "codeBlock",
|
|
997
|
-
attrs: { language: "js" },
|
|
998
|
-
content: [createText("s=''")],
|
|
999
|
-
},
|
|
1000
|
-
{
|
|
1001
|
-
type: "codeBlock",
|
|
1002
|
-
attrs: { language: "js" },
|
|
1003
|
-
content: [createText("// note")],
|
|
1004
|
-
},
|
|
1005
|
-
]),
|
|
1006
|
-
extensions: starterExtensions(),
|
|
1007
|
-
});
|
|
1008
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1009
|
-
const doc = el.ownerDocument;
|
|
1010
|
-
const editable = el.querySelector(".me-editor");
|
|
1011
|
-
const caretAt = (block, off) => {
|
|
1012
|
-
const holder = editable.querySelector(`[data-me-path='${block},0']`);
|
|
1013
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
1014
|
-
let acc = 0;
|
|
1015
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1016
|
-
const len = n.textContent?.length ?? 0;
|
|
1017
|
-
if (off <= acc + len) {
|
|
1018
|
-
doc
|
|
1019
|
-
.getSelection()
|
|
1020
|
-
.setBaseAndExtent(n, off - acc, n, off - acc);
|
|
1021
|
-
return;
|
|
1022
|
-
}
|
|
1023
|
-
acc += len;
|
|
1024
|
-
}
|
|
1025
|
-
};
|
|
1026
|
-
const fire = (data) => {
|
|
1027
|
-
const ev = new Event("beforeinput", {
|
|
1028
|
-
bubbles: true,
|
|
1029
|
-
cancelable: true,
|
|
1030
|
-
});
|
|
1031
|
-
Object.defineProperties(ev, {
|
|
1032
|
-
inputType: { value: "insertText" },
|
|
1033
|
-
data: { value: data },
|
|
1034
|
-
});
|
|
1035
|
-
editable.dispatchEvent(ev);
|
|
1036
|
-
};
|
|
1037
|
-
// 문자열 토큰 안의 { — 리터럴이어야 한다 (s='{' 가 s='{''}'로 깨지지 않게)
|
|
1038
|
-
caretAt(0, 3);
|
|
1039
|
-
fire("{");
|
|
1040
|
-
await flush?.();
|
|
1041
|
-
expect(el.querySelector("[data-me-path='0']").textContent).toBe("s='{'");
|
|
1042
|
-
// 라인 주석 끝의 { — 주석은 EOL까지 열려있어 리터럴
|
|
1043
|
-
caretAt(1, 7);
|
|
1044
|
-
fire("{");
|
|
1045
|
-
await flush?.();
|
|
1046
|
-
expect(el.querySelector("[data-me-path='1']").textContent).toBe("// note{");
|
|
1047
|
-
destroy();
|
|
1048
|
-
});
|
|
1049
|
-
it("코드블록 Enter — {|} 쌍 사이는 들여쓴 가운데 줄로 확장한다", async () => {
|
|
1050
|
-
const editor = createEditor({
|
|
1051
|
-
doc: createDoc([
|
|
1052
|
-
{ type: "codeBlock", content: [createText(" f(){}")] },
|
|
1053
|
-
]),
|
|
1054
|
-
extensions: starterExtensions(),
|
|
1055
|
-
});
|
|
1056
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1057
|
-
const doc = el.ownerDocument;
|
|
1058
|
-
const editable = el.querySelector(".me-editor");
|
|
1059
|
-
// 캐럿을 {|} 사이로 — 텍스트 노드에서 offset 6
|
|
1060
|
-
const holder = editable.querySelector("pre[data-me-path='0'] [data-me-path='0,0']");
|
|
1061
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
1062
|
-
let acc = 0;
|
|
1063
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1064
|
-
const len = n.textContent?.length ?? 0;
|
|
1065
|
-
if (6 <= acc + len) {
|
|
1066
|
-
doc.getSelection().setBaseAndExtent(n, 6 - acc, n, 6 - acc);
|
|
1067
|
-
break;
|
|
1068
|
-
}
|
|
1069
|
-
acc += len;
|
|
1070
|
-
}
|
|
1071
|
-
const ev = new Event("beforeinput", {
|
|
1072
|
-
bubbles: true,
|
|
1073
|
-
cancelable: true,
|
|
1074
|
-
});
|
|
1075
|
-
Object.defineProperty(ev, "inputType", {
|
|
1076
|
-
value: "insertParagraph",
|
|
1077
|
-
});
|
|
1078
|
-
editable.dispatchEvent(ev);
|
|
1079
|
-
await flush?.();
|
|
1080
|
-
// " f(){|\n" → " f(){\n \t|\n }" — VS Code/Ace 확장
|
|
1081
|
-
expect(el.querySelector("pre").textContent).toBe(" f(){\n \t\n }");
|
|
1082
|
-
destroy();
|
|
1083
|
-
});
|
|
1084
|
-
it("코드블록 키 편집 — Mod-/ 주석 · Alt-↓ 줄 이동 · Mod-Shift-D 복제", async () => {
|
|
1085
|
-
const editor = createEditor({
|
|
1086
|
-
doc: createDoc([
|
|
1087
|
-
{
|
|
1088
|
-
type: "codeBlock",
|
|
1089
|
-
attrs: { language: "js" },
|
|
1090
|
-
content: [createText("a\nb")],
|
|
1091
|
-
},
|
|
1092
|
-
]),
|
|
1093
|
-
extensions: starterExtensions(),
|
|
1094
|
-
});
|
|
1095
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1096
|
-
const editable = el.querySelector(".me-editor");
|
|
1097
|
-
const key = (init) => editable.dispatchEvent(new KeyboardEvent("keydown", {
|
|
1098
|
-
bubbles: true,
|
|
1099
|
-
cancelable: true,
|
|
1100
|
-
...init,
|
|
1101
|
-
}));
|
|
1102
|
-
// 캐럿을 첫 줄에 둔다
|
|
1103
|
-
editor.dispatch(editor.state
|
|
1104
|
-
.tr()
|
|
1105
|
-
.setSelection(textSelection(point([0, 0], 0))));
|
|
1106
|
-
await flush?.();
|
|
1107
|
-
// Mod-/ → // 주석 (들여쓰기 뒤)
|
|
1108
|
-
key({ key: "/", ctrlKey: true });
|
|
1109
|
-
await flush?.();
|
|
1110
|
-
expect(el.querySelector("pre").textContent).toBe("// a\nb");
|
|
1111
|
-
// Mod-/ 재토글 → 해제
|
|
1112
|
-
key({ key: "/", ctrlKey: true });
|
|
1113
|
-
await flush?.();
|
|
1114
|
-
expect(el.querySelector("pre").textContent).toBe("a\nb");
|
|
1115
|
-
// Alt-↓ — 첫 줄이 아래로 이동
|
|
1116
|
-
key({ key: "ArrowDown", altKey: true });
|
|
1117
|
-
await flush?.();
|
|
1118
|
-
expect(el.querySelector("pre").textContent).toBe("b\na");
|
|
1119
|
-
// Mod-Shift-D — 현재 줄("b") 복제
|
|
1120
|
-
editor.dispatch(editor.state
|
|
1121
|
-
.tr()
|
|
1122
|
-
.setSelection(textSelection(point([0, 0], 0))));
|
|
1123
|
-
await flush?.();
|
|
1124
|
-
key({ key: "d", ctrlKey: true, shiftKey: true });
|
|
1125
|
-
await flush?.();
|
|
1126
|
-
expect(el.querySelector("pre").textContent).toBe("b\nb\na");
|
|
1127
|
-
destroy();
|
|
1128
|
-
});
|
|
1129
|
-
it("코드블록 공백 조각은 .me-ws 스팬으로 분리된다 (showInvisibles 계약)", async () => {
|
|
1130
|
-
const editor = createEditor({
|
|
1131
|
-
doc: createDoc([
|
|
1132
|
-
{
|
|
1133
|
-
type: "codeBlock",
|
|
1134
|
-
content: [createText("a b\tc")],
|
|
1135
|
-
},
|
|
1136
|
-
]),
|
|
1137
|
-
extensions: starterExtensions(),
|
|
1138
|
-
});
|
|
1139
|
-
const { el, destroy } = await mount({ editor });
|
|
1140
|
-
// 스페이스 런 → .me-ws, 탭 → .me-ws-tab (텍스트는 원문 그대로)
|
|
1141
|
-
const pre = el.querySelector("pre");
|
|
1142
|
-
expect(pre.querySelector(".me-ws")?.textContent).toBe(" ");
|
|
1143
|
-
expect(pre.querySelector(".me-ws-tab")?.textContent).toBe("\t");
|
|
1144
|
-
expect(pre.textContent).toBe("a b\tc");
|
|
1145
|
-
destroy();
|
|
1146
|
-
});
|
|
1147
|
-
it("코드블록 .me-cl 요소 경계 클릭 — 캐럿이 줄 끝이 아닌 그 자리에 간다", async () => {
|
|
1148
|
-
const editor = createEditor({
|
|
1149
|
-
doc: createDoc([
|
|
1150
|
-
{
|
|
1151
|
-
type: "codeBlock",
|
|
1152
|
-
content: [createText("one\ntwo\nthree")],
|
|
1153
|
-
},
|
|
1154
|
-
]),
|
|
1155
|
-
extensions: starterExtensions(),
|
|
1156
|
-
});
|
|
1157
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1158
|
-
const doc = el.ownerDocument;
|
|
1159
|
-
const editable = el.querySelector(".me-editor");
|
|
1160
|
-
// 두 번째 줄 래퍼(.me-cl)의 요소 경계 (meCl, 0) — 줄 시작 클릭.
|
|
1161
|
-
// 래퍼는 뒤쪽 개행을 포함한다 ("one\n" | "two\n" | "three")
|
|
1162
|
-
const meCl = editable.querySelectorAll(".me-cl")[1];
|
|
1163
|
-
expect(meCl.textContent).toBe("two\n");
|
|
1164
|
-
doc.getSelection().setBaseAndExtent(meCl, 0, meCl, 0);
|
|
1165
|
-
doc.dispatchEvent(new Event("selectionchange"));
|
|
1166
|
-
await flush?.();
|
|
1167
|
-
// (meCl,0)은 offset 4 ("one\n" 뒤) — 버그 전엔 요소 전체
|
|
1168
|
-
// textContent를 더해 offset 7~8(줄 끝 너머)로 갔다
|
|
1169
|
-
const sel = editor.state.selection;
|
|
1170
|
-
if (sel.type === "text") {
|
|
1171
|
-
expect(sel.anchor.offset).toBe(4);
|
|
1172
|
-
}
|
|
1173
|
-
// (meCl, 끝) 경계도 자식 인덱스가 아닌 문자 오프셋으로 환산된다
|
|
1174
|
-
doc
|
|
1175
|
-
.getSelection()
|
|
1176
|
-
.setBaseAndExtent(meCl, meCl.childNodes.length, meCl, meCl.childNodes.length);
|
|
1177
|
-
doc.dispatchEvent(new Event("selectionchange"));
|
|
1178
|
-
await flush?.();
|
|
1179
|
-
const sel2 = editor.state.selection;
|
|
1180
|
-
if (sel2.type === "text") {
|
|
1181
|
-
expect(sel2.anchor.offset).toBe(8);
|
|
1182
|
-
}
|
|
1183
|
-
destroy();
|
|
1184
|
-
});
|
|
1185
|
-
it("Shift-Tab — 들여쓰기가 없는 코드 줄에서도 소비된다 (포커스 이탈 방지)", async () => {
|
|
1186
|
-
const editor = createEditor({
|
|
1187
|
-
doc: createDoc([
|
|
1188
|
-
{ type: "codeBlock", content: [createText("abc")] },
|
|
1189
|
-
]),
|
|
1190
|
-
extensions: starterExtensions(),
|
|
1191
|
-
});
|
|
1192
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1193
|
-
const editable = el.querySelector(".me-editor");
|
|
1194
|
-
editor.dispatch(editor.state.tr().setSelection(textSelection(point([0, 0], 1))));
|
|
1195
|
-
await flush?.();
|
|
1196
|
-
const ev = new KeyboardEvent("keydown", {
|
|
1197
|
-
key: "Tab",
|
|
1198
|
-
shiftKey: true,
|
|
1199
|
-
bubbles: true,
|
|
1200
|
-
cancelable: true,
|
|
1201
|
-
});
|
|
1202
|
-
editable.dispatchEvent(ev);
|
|
1203
|
-
// false 폴스루면 브라우저가 포커스를 빼앗는다 — preventDefault 필수
|
|
1204
|
-
expect(ev.defaultPrevented).toBe(true);
|
|
1205
|
-
expect(el.querySelector("pre").textContent).toBe("abc");
|
|
1206
|
-
destroy();
|
|
1207
|
-
});
|
|
1208
|
-
it("insertFromDrop — 텍스트 드롭은 트랜잭션으로 삽입된다", async () => {
|
|
1209
|
-
const editor = createEditor({
|
|
1210
|
-
doc: createDoc([
|
|
1211
|
-
{ type: "paragraph", content: [createText("ab")] },
|
|
1212
|
-
]),
|
|
1213
|
-
extensions: starterExtensions(),
|
|
1214
|
-
});
|
|
1215
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1216
|
-
const doc = el.ownerDocument;
|
|
1217
|
-
const editable = el.querySelector(".me-editor");
|
|
1218
|
-
const holder = editable.querySelector("[data-me-path='0,0']");
|
|
1219
|
-
// 렌더러마다 텍스트 노드 분할이 다르다 — 마지막 텍스트 노드 끝에 캐럿
|
|
1220
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
1221
|
-
let t = null;
|
|
1222
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1223
|
-
if (n.textContent)
|
|
1224
|
-
t = n;
|
|
1225
|
-
}
|
|
1226
|
-
doc
|
|
1227
|
-
.getSelection()
|
|
1228
|
-
.setBaseAndExtent(t, t.textContent.length, t, t.textContent.length);
|
|
1229
|
-
const ev = new Event("beforeinput", {
|
|
1230
|
-
bubbles: true,
|
|
1231
|
-
cancelable: true,
|
|
1232
|
-
});
|
|
1233
|
-
Object.defineProperties(ev, {
|
|
1234
|
-
inputType: { value: "insertFromDrop" },
|
|
1235
|
-
data: { value: "XY" },
|
|
1236
|
-
});
|
|
1237
|
-
editable.dispatchEvent(ev);
|
|
1238
|
-
await flush?.();
|
|
1239
|
-
expect(el.querySelector("p").textContent).toBe("abXY");
|
|
1240
|
-
destroy();
|
|
1241
|
-
});
|
|
1242
|
-
it("historyUndo/historyRedo beforeinput — 우리 히스토리로 돌린다", async () => {
|
|
1243
|
-
const editor = createEditor({
|
|
1244
|
-
doc: createDoc([
|
|
1245
|
-
{ type: "paragraph", content: [createText("a")] },
|
|
1246
|
-
]),
|
|
1247
|
-
extensions: starterExtensions(),
|
|
1248
|
-
});
|
|
1249
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1250
|
-
const doc = el.ownerDocument;
|
|
1251
|
-
const editable = el.querySelector(".me-editor");
|
|
1252
|
-
const holder = editable.querySelector("[data-me-path='0,0']");
|
|
1253
|
-
// 렌더러마다 텍스트 노드 분할이 다르다 — 마지막 텍스트 노드 끝에 캐럿
|
|
1254
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
1255
|
-
let t = null;
|
|
1256
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1257
|
-
if (n.textContent)
|
|
1258
|
-
t = n;
|
|
1259
|
-
}
|
|
1260
|
-
doc
|
|
1261
|
-
.getSelection()
|
|
1262
|
-
.setBaseAndExtent(t, t.textContent.length, t, t.textContent.length);
|
|
1263
|
-
const fire = (inputType, data) => {
|
|
1264
|
-
const ev = new Event("beforeinput", {
|
|
1265
|
-
bubbles: true,
|
|
1266
|
-
cancelable: true,
|
|
1267
|
-
});
|
|
1268
|
-
Object.defineProperties(ev, {
|
|
1269
|
-
inputType: { value: inputType },
|
|
1270
|
-
data: { value: data },
|
|
1271
|
-
});
|
|
1272
|
-
editable.dispatchEvent(ev);
|
|
1273
|
-
return ev;
|
|
1274
|
-
};
|
|
1275
|
-
// 먼저 실제 편집 하나 — undo 스택에 쌓이게
|
|
1276
|
-
fire("insertText", "b");
|
|
1277
|
-
await flush?.();
|
|
1278
|
-
expect(el.querySelector("p").textContent).toBe("ab");
|
|
1279
|
-
// iOS/Android 실행취소 제스처 → preventDefault + undo
|
|
1280
|
-
const u = fire("historyUndo");
|
|
1281
|
-
expect(u.defaultPrevented).toBe(true);
|
|
1282
|
-
await flush?.();
|
|
1283
|
-
expect(el.querySelector("p").textContent).toBe("a");
|
|
1284
|
-
fire("historyRedo");
|
|
1285
|
-
await flush?.();
|
|
1286
|
-
expect(el.querySelector("p").textContent).toBe("ab");
|
|
1287
|
-
destroy();
|
|
1288
|
-
});
|
|
1289
|
-
it("deleteByCut — 잘라내기가 트랜잭션으로 선택을 지운다", async () => {
|
|
1290
|
-
// Cmd-X: 방치하면 브라우저가 DOM만 지워 doc과 어긋난다
|
|
1291
|
-
const editor = createEditor({
|
|
1292
|
-
doc: createDoc([
|
|
1293
|
-
{ type: "paragraph", content: [createText("ab")] },
|
|
1294
|
-
]),
|
|
1295
|
-
extensions: starterExtensions(),
|
|
1296
|
-
});
|
|
1297
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1298
|
-
const doc = el.ownerDocument;
|
|
1299
|
-
const editable = el.querySelector(".me-editor");
|
|
1300
|
-
const holder = editable.querySelector("[data-me-path='0,0']");
|
|
1301
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
1302
|
-
let t = null;
|
|
1303
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1304
|
-
if (n.textContent === "ab")
|
|
1305
|
-
t = n;
|
|
1306
|
-
}
|
|
1307
|
-
doc.getSelection().setBaseAndExtent(t, 0, t, 2);
|
|
1308
|
-
const ev = new Event("beforeinput", {
|
|
1309
|
-
bubbles: true,
|
|
1310
|
-
cancelable: true,
|
|
1311
|
-
});
|
|
1312
|
-
Object.defineProperties(ev, {
|
|
1313
|
-
inputType: { value: "deleteByCut" },
|
|
1314
|
-
});
|
|
1315
|
-
editable.dispatchEvent(ev);
|
|
1316
|
-
await flush?.();
|
|
1317
|
-
expect(ev.defaultPrevented).toBe(true);
|
|
1318
|
-
expect(el.querySelector("p").textContent).toBe("");
|
|
1319
|
-
destroy();
|
|
1320
|
-
});
|
|
1321
|
-
it("deleteWord* — getTargetRanges 범위를 트랜잭션으로 지운다", async () => {
|
|
1322
|
-
// macOS Option-Delete: 방치하면 네이티브 단어 삭제가 doc을 우회
|
|
1323
|
-
const editor = createEditor({
|
|
1324
|
-
doc: createDoc([
|
|
1325
|
-
{ type: "paragraph", content: [createText("foo bar")] },
|
|
1326
|
-
]),
|
|
1327
|
-
extensions: starterExtensions(),
|
|
1328
|
-
});
|
|
1329
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1330
|
-
const doc = el.ownerDocument;
|
|
1331
|
-
const editable = el.querySelector(".me-editor");
|
|
1332
|
-
const holder = editable.querySelector("[data-me-path='0,0']");
|
|
1333
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
1334
|
-
let t = null;
|
|
1335
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1336
|
-
if (n.textContent === "foo bar")
|
|
1337
|
-
t = n;
|
|
1338
|
-
}
|
|
1339
|
-
doc.getSelection().setBaseAndExtent(t, 7, t, 7);
|
|
1340
|
-
const ev = new Event("beforeinput", {
|
|
1341
|
-
bubbles: true,
|
|
1342
|
-
cancelable: true,
|
|
1343
|
-
});
|
|
1344
|
-
Object.defineProperties(ev, {
|
|
1345
|
-
inputType: { value: "deleteWordBackward" },
|
|
1346
|
-
// happy-dom에는 getTargetRanges가 없으므로 오리타이핑으로
|
|
1347
|
-
// 붙인다 — 코드는 startContainer/startOffset만 읽는다
|
|
1348
|
-
getTargetRanges: {
|
|
1349
|
-
value: () => [
|
|
1350
|
-
{
|
|
1351
|
-
startContainer: t,
|
|
1352
|
-
startOffset: 4,
|
|
1353
|
-
endContainer: t,
|
|
1354
|
-
endOffset: 7,
|
|
1355
|
-
},
|
|
1356
|
-
],
|
|
1357
|
-
},
|
|
1358
|
-
});
|
|
1359
|
-
editable.dispatchEvent(ev);
|
|
1360
|
-
await flush?.();
|
|
1361
|
-
expect(ev.defaultPrevented).toBe(true);
|
|
1362
|
-
expect(el.querySelector("p").textContent).toBe("foo ");
|
|
1363
|
-
destroy();
|
|
1364
|
-
});
|
|
1365
|
-
it("이모지 Backspace — 서로게이트 페어가 통째로 지워진다", async () => {
|
|
1366
|
-
// getTargetRanges가 없으면 코드 유닛 하나만 지워 절반 페어가
|
|
1367
|
-
// 남는다 — 브라우저가 주는 그래피임 범위를 우선 쓴다
|
|
1368
|
-
const editor = createEditor({
|
|
1369
|
-
doc: createDoc([
|
|
1370
|
-
{ type: "paragraph", content: [createText("a😀b")] },
|
|
1371
|
-
]),
|
|
1372
|
-
extensions: starterExtensions(),
|
|
1373
|
-
});
|
|
1374
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1375
|
-
const doc = el.ownerDocument;
|
|
1376
|
-
const editable = el.querySelector(".me-editor");
|
|
1377
|
-
const holder = editable.querySelector("[data-me-path='0,0']");
|
|
1378
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
1379
|
-
let t = null;
|
|
1380
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1381
|
-
if (n.textContent === "a😀b")
|
|
1382
|
-
t = n;
|
|
1383
|
-
}
|
|
1384
|
-
// 캐럿은 😀 뒤 (a=1 + 서로게이트 페어 2 = 오프셋 3)
|
|
1385
|
-
doc.getSelection().setBaseAndExtent(t, 3, t, 3);
|
|
1386
|
-
const ev = new Event("beforeinput", {
|
|
1387
|
-
bubbles: true,
|
|
1388
|
-
cancelable: true,
|
|
1389
|
-
});
|
|
1390
|
-
Object.defineProperties(ev, {
|
|
1391
|
-
inputType: { value: "deleteContentBackward" },
|
|
1392
|
-
getTargetRanges: {
|
|
1393
|
-
value: () => [
|
|
1394
|
-
{
|
|
1395
|
-
startContainer: t,
|
|
1396
|
-
startOffset: 1,
|
|
1397
|
-
endContainer: t,
|
|
1398
|
-
endOffset: 3,
|
|
1399
|
-
},
|
|
1400
|
-
],
|
|
1401
|
-
},
|
|
1402
|
-
});
|
|
1403
|
-
editable.dispatchEvent(ev);
|
|
1404
|
-
await flush?.();
|
|
1405
|
-
expect(el.querySelector("p").textContent).toBe("ab");
|
|
1406
|
-
destroy();
|
|
1407
|
-
});
|
|
1408
|
-
it("format*/insertOrderedList — 컨텍스트 메뉴 서식이 우리 커맨드로 매핑된다", async () => {
|
|
1409
|
-
const editor = createEditor({
|
|
1410
|
-
doc: createDoc([
|
|
1411
|
-
{ type: "paragraph", content: [createText("ab")] },
|
|
1412
|
-
]),
|
|
1413
|
-
extensions: starterExtensions(),
|
|
1414
|
-
});
|
|
1415
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1416
|
-
const doc = el.ownerDocument;
|
|
1417
|
-
const editable = el.querySelector(".me-editor");
|
|
1418
|
-
const holder = editable.querySelector("[data-me-path='0,0']");
|
|
1419
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
1420
|
-
let t = null;
|
|
1421
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1422
|
-
if (n.textContent === "ab")
|
|
1423
|
-
t = n;
|
|
1424
|
-
}
|
|
1425
|
-
const fire = (inputType) => {
|
|
1426
|
-
const ev = new Event("beforeinput", {
|
|
1427
|
-
bubbles: true,
|
|
1428
|
-
cancelable: true,
|
|
1429
|
-
});
|
|
1430
|
-
Object.defineProperties(ev, {
|
|
1431
|
-
inputType: { value: inputType },
|
|
1432
|
-
});
|
|
1433
|
-
editable.dispatchEvent(ev);
|
|
1434
|
-
return ev;
|
|
1435
|
-
};
|
|
1436
|
-
// 선택 후 formatBold → bold 마크
|
|
1437
|
-
doc.getSelection().setBaseAndExtent(t, 0, t, 2);
|
|
1438
|
-
const b = fire("formatBold");
|
|
1439
|
-
expect(b.defaultPrevented).toBe(true);
|
|
1440
|
-
await flush?.();
|
|
1441
|
-
expect(el.querySelector("strong")?.textContent).toBe("ab");
|
|
1442
|
-
// 캐럿에서 insertOrderedList → orderedList 변환
|
|
1443
|
-
doc.getSelection().setBaseAndExtent(t, 0, t, 0);
|
|
1444
|
-
fire("insertOrderedList");
|
|
1445
|
-
await flush?.();
|
|
1446
|
-
expect(el.querySelector(".me-editor > ol")).toBeTruthy();
|
|
1447
|
-
destroy();
|
|
1448
|
-
});
|
|
1449
|
-
it("미지원 inputType은 no-op — 네이티브 DOM 변경을 막는다", async () => {
|
|
1450
|
-
// 처리하지 않는 타입도 preventDefault해야 브라우저가 DOM만
|
|
1451
|
-
// 바꿔 doc과 어긋나는 일이 없다 (insertTranspose, formatFontColor…)
|
|
1452
|
-
const editor = createEditor({
|
|
1453
|
-
doc: createDoc([
|
|
1454
|
-
{ type: "paragraph", content: [createText("ab")] },
|
|
1455
|
-
]),
|
|
1456
|
-
extensions: starterExtensions(),
|
|
1457
|
-
});
|
|
1458
|
-
const { el, destroy } = await mount({ editor });
|
|
1459
|
-
const editable = el.querySelector(".me-editor");
|
|
1460
|
-
const ev = new Event("beforeinput", {
|
|
1461
|
-
bubbles: true,
|
|
1462
|
-
cancelable: true,
|
|
1463
|
-
});
|
|
1464
|
-
Object.defineProperties(ev, {
|
|
1465
|
-
inputType: { value: "formatFontColor" },
|
|
1466
|
-
});
|
|
1467
|
-
editable.dispatchEvent(ev);
|
|
1468
|
-
expect(ev.defaultPrevented).toBe(true);
|
|
1469
|
-
expect(el.querySelector("p").textContent).toBe("ab");
|
|
1470
|
-
destroy();
|
|
1471
|
-
});
|
|
1472
|
-
it("코드블록 붙여넣기 — HTML도 항상 순수 텍스트로 들어간다", async () => {
|
|
1473
|
-
const editor = createEditor({
|
|
1474
|
-
doc: createDoc([
|
|
1475
|
-
{ type: "codeBlock", content: [createText("x")] },
|
|
1476
|
-
]),
|
|
1477
|
-
extensions: starterExtensions(),
|
|
1478
|
-
});
|
|
1479
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1480
|
-
const doc = el.ownerDocument;
|
|
1481
|
-
const editable = el.querySelector(".me-editor");
|
|
1482
|
-
const holder = editable.querySelector("[data-me-path='0,0']");
|
|
1483
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
1484
|
-
let t = null;
|
|
1485
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1486
|
-
if (n.textContent)
|
|
1487
|
-
t = n;
|
|
1488
|
-
}
|
|
1489
|
-
doc
|
|
1490
|
-
.getSelection()
|
|
1491
|
-
.setBaseAndExtent(t, t.textContent.length, t, t.textContent.length);
|
|
1492
|
-
const ev = new Event("beforeinput", {
|
|
1493
|
-
bubbles: true,
|
|
1494
|
-
cancelable: true,
|
|
1495
|
-
});
|
|
1496
|
-
const dt = {
|
|
1497
|
-
getData: (fmt) => fmt === "text/html" ? "<b><div></b>" : "<div>",
|
|
1498
|
-
};
|
|
1499
|
-
Object.defineProperties(ev, {
|
|
1500
|
-
inputType: { value: "insertFromPaste" },
|
|
1501
|
-
dataTransfer: { value: dt },
|
|
1502
|
-
});
|
|
1503
|
-
editable.dispatchEvent(ev);
|
|
1504
|
-
await flush?.();
|
|
1505
|
-
// 마크업이 노드가 아니라 문자로 들어가야 한다
|
|
1506
|
-
expect(el.querySelector("pre").textContent).toBe("x<div>");
|
|
1507
|
-
expect(el.querySelector("pre b")).toBeNull();
|
|
1508
|
-
destroy();
|
|
1509
|
-
});
|
|
1510
|
-
it("할일 마커 클릭은 checked를 토글한다", async () => {
|
|
1511
|
-
const editor = createEditor({
|
|
1512
|
-
doc: createDoc([
|
|
1513
|
-
{
|
|
1514
|
-
type: "taskList",
|
|
1515
|
-
content: [
|
|
1516
|
-
{
|
|
1517
|
-
type: "taskItem",
|
|
1518
|
-
attrs: { checked: false },
|
|
1519
|
-
content: [
|
|
1520
|
-
{ type: "paragraph", content: [createText("할일")] },
|
|
1521
|
-
],
|
|
1522
|
-
},
|
|
1523
|
-
],
|
|
1524
|
-
},
|
|
1525
|
-
]),
|
|
1526
|
-
extensions: starterExtensions(),
|
|
1527
|
-
});
|
|
1528
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1529
|
-
const li = el.querySelector("li.me-task[data-me-path='0,0']");
|
|
1530
|
-
expect(li.getAttribute("data-checked")).toBe("false");
|
|
1531
|
-
// 마커 존(li 패딩) 클릭 — 타겟이 li 자신이고 offsetX<=28
|
|
1532
|
-
const ev = new MouseEvent("mousedown", {
|
|
1533
|
-
bubbles: true,
|
|
1534
|
-
cancelable: true,
|
|
1535
|
-
});
|
|
1536
|
-
Object.defineProperty(ev, "offsetX", { value: 10 });
|
|
1537
|
-
li.dispatchEvent(ev);
|
|
1538
|
-
await flush?.();
|
|
1539
|
-
expect(el
|
|
1540
|
-
.querySelector("li.me-task[data-me-path='0,0']")
|
|
1541
|
-
?.getAttribute("data-checked")).toBe("true");
|
|
1542
|
-
expect(editor.state.doc.content[0]?.content?.[0]?.attrs?.["checked"]).toBe(true);
|
|
1543
|
-
destroy();
|
|
1544
|
-
});
|
|
1545
|
-
it("이미지 클릭 — 노드 셀렉션 + .me-imgui 오버레이 + Backspace 삭제", async () => {
|
|
1546
|
-
const editor = createEditor({
|
|
1547
|
-
doc: createDoc([
|
|
1548
|
-
{ type: "paragraph", content: [createText("위")] },
|
|
1549
|
-
{ type: "image", attrs: { src: "a.png", alt: "" } },
|
|
1550
|
-
{ type: "paragraph", content: [createText("아래")] },
|
|
1551
|
-
]),
|
|
1552
|
-
extensions: starterExtensions(),
|
|
1553
|
-
});
|
|
1554
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1555
|
-
const editable = el.querySelector(".me-editor");
|
|
1556
|
-
// img 클릭 → NodeSelection + is-selected 링 + .me-imgui 오버레이
|
|
1557
|
-
editable
|
|
1558
|
-
.querySelector("img[data-me-path='1']")
|
|
1559
|
-
.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
|
|
1560
|
-
await flush?.();
|
|
1561
|
-
expect(editor.state.selection.type).toBe("node");
|
|
1562
|
-
// 오버레이는 .me-editor의 형제로 뜬다 — DOM↔Point 매핑 불침범
|
|
1563
|
-
const ui = el.querySelector(".me-imgui");
|
|
1564
|
-
expect(ui).toBeTruthy();
|
|
1565
|
-
expect(ui.hidden).toBe(false);
|
|
1566
|
-
expect(ui.querySelector(".me-img-handle")).toBeTruthy();
|
|
1567
|
-
expect(editable
|
|
1568
|
-
.querySelector("img[data-me-path='1']")
|
|
1569
|
-
.classList.contains("is-selected")).toBe(true);
|
|
1570
|
-
// 노드 셀렉션 상태의 Backspace는 이미지를 통째로 지운다
|
|
1571
|
-
editable.dispatchEvent(new KeyboardEvent("keydown", {
|
|
1572
|
-
key: "Backspace",
|
|
1573
|
-
bubbles: true,
|
|
1574
|
-
cancelable: true,
|
|
1575
|
-
}));
|
|
1576
|
-
await flush?.();
|
|
1577
|
-
expect(editor.state.doc.content.map((n) => n.type)).toEqual([
|
|
1578
|
-
"paragraph",
|
|
1579
|
-
"paragraph",
|
|
1580
|
-
]);
|
|
1581
|
-
expect(ui.hidden).toBe(true);
|
|
1582
|
-
destroy();
|
|
1583
|
-
});
|
|
1584
|
-
it("표 안 캐럿 — .me-tableui 오버레이(행/열 버튼 + 열 핸들)가 뜬다", async () => {
|
|
1585
|
-
const cell = () => ({
|
|
1586
|
-
type: "tableCell",
|
|
1587
|
-
content: [
|
|
1588
|
-
{ type: "paragraph", content: [createText("x")] },
|
|
1589
|
-
],
|
|
1590
|
-
});
|
|
1591
|
-
const editor = createEditor({
|
|
1592
|
-
doc: createDoc([
|
|
1593
|
-
{
|
|
1594
|
-
type: "table",
|
|
1595
|
-
content: [0, 1].map(() => ({
|
|
1596
|
-
type: "tableRow",
|
|
1597
|
-
content: [cell(), cell()],
|
|
1598
|
-
})),
|
|
1599
|
-
},
|
|
1600
|
-
{ type: "paragraph", content: [createText("밖")] },
|
|
1601
|
-
]),
|
|
1602
|
-
extensions: starterExtensions(),
|
|
1603
|
-
});
|
|
1604
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1605
|
-
// 셀(0,0) 안 문단으로 캐럿 → 오버레이 표시
|
|
1606
|
-
editor.dispatch(editor.state
|
|
1607
|
-
.tr()
|
|
1608
|
-
.setSelection(textSelection(point([0, 0, 0, 0], 0))));
|
|
1609
|
-
await flush?.();
|
|
1610
|
-
const ui = el.querySelector(".me-tableui");
|
|
1611
|
-
expect(ui).toBeTruthy();
|
|
1612
|
-
expect(ui.hidden).toBe(false);
|
|
1613
|
-
// 행/열 추가·삭제 + 표 삭제 버튼과 열 경계 핸들 (2열 → 경계 1개)
|
|
1614
|
-
expect(ui.querySelectorAll("[data-tbl-act]").length).toBe(5);
|
|
1615
|
-
expect(ui.querySelectorAll(".me-col-handle").length).toBe(1);
|
|
1616
|
-
// 표 밖으로 캐럿이 나가면 숨긴다
|
|
1617
|
-
editor.dispatch(editor.state
|
|
1618
|
-
.tr()
|
|
1619
|
-
.setSelection(textSelection(point([1, 0], 0))));
|
|
1620
|
-
await flush?.();
|
|
1621
|
-
expect(ui.hidden).toBe(true);
|
|
1622
|
-
destroy();
|
|
1623
|
-
});
|
|
1624
|
-
it("코드블록 안 캐럿 — .me-codeui 크롬 바(언어·정리·복사)가 뜬다", async () => {
|
|
1625
|
-
const editor = createEditor({
|
|
1626
|
-
doc: createDoc([
|
|
1627
|
-
{
|
|
1628
|
-
type: "codeBlock",
|
|
1629
|
-
attrs: { language: "js" },
|
|
1630
|
-
content: [createText("const a = 1")],
|
|
1631
|
-
},
|
|
1632
|
-
{ type: "paragraph", content: [createText("밖")] },
|
|
1633
|
-
]),
|
|
1634
|
-
extensions: starterExtensions(),
|
|
1635
|
-
});
|
|
1636
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1637
|
-
// 캐럿이 코드 안 → 오버레이 표시. .me-editor 형제라 매핑 불침범
|
|
1638
|
-
editor.dispatch(editor.state.tr().setSelection(textSelection(point([0, 0], 2))));
|
|
1639
|
-
await flush?.();
|
|
1640
|
-
const ui = el.querySelector(".me-codeui");
|
|
1641
|
-
expect(ui).toBeTruthy();
|
|
1642
|
-
expect(ui.hidden).toBe(false);
|
|
1643
|
-
const sel = ui.querySelector(".me-codelang");
|
|
1644
|
-
expect(sel.value).toBe("js");
|
|
1645
|
-
expect(ui.querySelectorAll("[data-code-act]").length).toBe(2);
|
|
1646
|
-
// 언어 변경 → setCodeLanguage → data-language attr
|
|
1647
|
-
sel.value = "bash";
|
|
1648
|
-
sel.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1649
|
-
await flush?.();
|
|
1650
|
-
expect(el
|
|
1651
|
-
.querySelector("pre[data-me-path='0']")
|
|
1652
|
-
?.getAttribute("data-language")).toBe("bash");
|
|
1653
|
-
// 코드 밖으로 캐럿이 나가면 숨긴다
|
|
1654
|
-
editor.dispatch(editor.state.tr().setSelection(textSelection(point([1, 0], 0))));
|
|
1655
|
-
await flush?.();
|
|
1656
|
-
expect(ui.hidden).toBe(true);
|
|
1657
|
-
destroy();
|
|
1658
|
-
});
|
|
1659
|
-
it("'[ ] ' 입력 규칙은 할일 목록을 만든다", async () => {
|
|
1660
|
-
const editor = createEditor({
|
|
1661
|
-
doc: createDoc([
|
|
1662
|
-
{ type: "paragraph", content: [createText("[ ]")] },
|
|
1663
|
-
]),
|
|
1664
|
-
extensions: starterExtensions(),
|
|
1665
|
-
});
|
|
1666
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1667
|
-
const doc = el.ownerDocument;
|
|
1668
|
-
const editable = el.querySelector(".me-editor");
|
|
1669
|
-
const holder = editable.querySelector("[data-me-path='0,0']");
|
|
1670
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
1671
|
-
let t = null;
|
|
1672
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1673
|
-
if (n.textContent === "[ ]")
|
|
1674
|
-
t = n;
|
|
1675
|
-
}
|
|
1676
|
-
doc.getSelection().setBaseAndExtent(t, 3, t, 3);
|
|
1677
|
-
const ev = new Event("beforeinput", {
|
|
1678
|
-
bubbles: true,
|
|
1679
|
-
cancelable: true,
|
|
1680
|
-
});
|
|
1681
|
-
Object.defineProperties(ev, {
|
|
1682
|
-
inputType: { value: "insertText" },
|
|
1683
|
-
data: { value: " " },
|
|
1684
|
-
});
|
|
1685
|
-
editable.dispatchEvent(ev);
|
|
1686
|
-
await flush?.();
|
|
1687
|
-
expect(el.querySelector("ul.me-tasks > li.me-task > p")).toBeTruthy();
|
|
1688
|
-
destroy();
|
|
1689
|
-
});
|
|
1690
|
-
it("표 셀 안 타이핑과 Tab 셀 이동이 동작한다", async () => {
|
|
1691
|
-
const editor = createEditor({
|
|
1692
|
-
doc: createDoc([
|
|
1693
|
-
{
|
|
1694
|
-
type: "table",
|
|
1695
|
-
content: [
|
|
1696
|
-
{
|
|
1697
|
-
type: "tableRow",
|
|
1698
|
-
content: [
|
|
1699
|
-
{
|
|
1700
|
-
type: "tableCell",
|
|
1701
|
-
content: [
|
|
1702
|
-
{ type: "paragraph", content: [createText("a")] },
|
|
1703
|
-
],
|
|
1704
|
-
},
|
|
1705
|
-
{
|
|
1706
|
-
type: "tableCell",
|
|
1707
|
-
content: [
|
|
1708
|
-
{ type: "paragraph", content: [createText("b")] },
|
|
1709
|
-
],
|
|
1710
|
-
},
|
|
1711
|
-
],
|
|
1712
|
-
},
|
|
1713
|
-
],
|
|
1714
|
-
},
|
|
1715
|
-
]),
|
|
1716
|
-
extensions: starterExtensions(),
|
|
1717
|
-
});
|
|
1718
|
-
const { el, flush, destroy } = await mount({ editor });
|
|
1719
|
-
const doc = el.ownerDocument;
|
|
1720
|
-
const editable = el.querySelector(".me-editor");
|
|
1721
|
-
// 첫 셀의 텍스트 끝에 캐럿 → Tab → 두 번째 셀로
|
|
1722
|
-
const holder = editable.querySelector("[data-me-path='0,0,0,0,0']");
|
|
1723
|
-
const walker = doc.createTreeWalker(holder, 4);
|
|
1724
|
-
let t = null;
|
|
1725
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1726
|
-
if (n.textContent === "a")
|
|
1727
|
-
t = n;
|
|
1728
|
-
}
|
|
1729
|
-
doc.getSelection().setBaseAndExtent(t, 1, t, 1);
|
|
1730
|
-
editable.dispatchEvent(new KeyboardEvent("keydown", {
|
|
1731
|
-
key: "Tab",
|
|
1732
|
-
bubbles: true,
|
|
1733
|
-
cancelable: true,
|
|
1734
|
-
}));
|
|
1735
|
-
await flush?.();
|
|
1736
|
-
if (editor.state.selection.type === "text") {
|
|
1737
|
-
expect(editor.state.selection.anchor.path.slice(0, 3)).toEqual([0, 0, 1]);
|
|
1738
|
-
}
|
|
1739
|
-
destroy();
|
|
1740
|
-
});
|
|
1741
|
-
it("destroy 후 에디터 루트를 제거한다", async () => {
|
|
1742
|
-
const editor = specEditor();
|
|
1743
|
-
const { el, destroy } = await mount({ editor });
|
|
1744
|
-
destroy();
|
|
1745
|
-
expect(el.querySelector(".me-editor")).toBeNull();
|
|
1746
|
-
});
|
|
1747
|
-
});
|
|
1748
|
-
}
|
|
1
|
+
// conformance 스펙은 conformance/ 디렉터리로 분리됐다 —
|
|
2
|
+
// 이 파일은 기존 import 경로를 유지하는 재수출 shim이다.
|
|
3
|
+
export * from "./conformance/index.js";
|
|
1749
4
|
//# sourceMappingURL=conformance.js.map
|