@teriusu/rich-editor 0.9.13 → 0.10.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.
Files changed (38) hide show
  1. package/dist/components/FixedToolbar.svelte +18 -3
  2. package/dist/components/FixedToolbar.svelte.d.ts.map +1 -1
  3. package/dist/components/SlashCommandMenu.svelte +18 -3
  4. package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -1
  5. package/dist/components/TipTapEditor.svelte +7 -0
  6. package/dist/components/TipTapEditor.svelte.d.ts.map +1 -1
  7. package/dist/components/icons/ToggleHeading1.svelte +12 -0
  8. package/dist/components/icons/ToggleHeading1.svelte.d.ts +7 -0
  9. package/dist/components/icons/ToggleHeading1.svelte.d.ts.map +1 -0
  10. package/dist/components/icons/ToggleHeading2.svelte +8 -0
  11. package/dist/components/icons/ToggleHeading2.svelte.d.ts +7 -0
  12. package/dist/components/icons/ToggleHeading2.svelte.d.ts.map +1 -0
  13. package/dist/components/icons/ToggleHeading3.svelte +8 -0
  14. package/dist/components/icons/ToggleHeading3.svelte.d.ts +7 -0
  15. package/dist/components/icons/ToggleHeading3.svelte.d.ts.map +1 -0
  16. package/dist/components/icons/ToggleHeadingIcon.svelte +68 -0
  17. package/dist/components/icons/ToggleHeadingIcon.svelte.d.ts +9 -0
  18. package/dist/components/icons/ToggleHeadingIcon.svelte.d.ts.map +1 -0
  19. package/dist/extensions/TabsBlock.d.ts +18 -0
  20. package/dist/extensions/TabsBlock.d.ts.map +1 -0
  21. package/dist/extensions/TabsBlock.js +697 -0
  22. package/dist/extensions/TabsBlock.test.d.ts +2 -0
  23. package/dist/extensions/TabsBlock.test.d.ts.map +1 -0
  24. package/dist/extensions/TabsBlock.test.js +539 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -0
  28. package/dist/styles/editor.css +1 -1
  29. package/dist/types.d.ts +1 -1
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/types.js +2 -2
  32. package/dist/utils/lowlight.d.ts +1 -1
  33. package/dist/utils/lowlight.d.ts.map +1 -1
  34. package/dist/utils/lowlight.js +12 -1
  35. package/dist/utils/lowlight.test.js +14 -0
  36. package/dist/utils/sanitize.d.ts.map +1 -1
  37. package/dist/utils/sanitize.js +2 -0
  38. package/package.json +1 -1
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=TabsBlock.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TabsBlock.test.d.ts","sourceRoot":"","sources":["../../src/extensions/TabsBlock.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,539 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { Editor } from "@tiptap/core";
3
+ import StarterKit from "@tiptap/starter-kit";
4
+ import { TabsBlock, Tab } from "./TabsBlock";
5
+ import { sanitizeHtml } from "../utils/sanitize";
6
+ let editor = null;
7
+ let host = null;
8
+ /**
9
+ * ⚠️ NodeView 를 함께 검사하므로 에디터를 **문서에 붙인다.** 떼어 놓은 `div` 에 세우면
10
+ * 칩 클릭 같은 이벤트가 흐르지 않아 탭바 동작을 못 본다.
11
+ */
12
+ const make = (content = "<p></p>", editable = true) => {
13
+ host = document.createElement("div");
14
+ document.body.appendChild(host);
15
+ editor = new Editor({
16
+ element: host,
17
+ extensions: [StarterKit, TabsBlock, Tab],
18
+ content,
19
+ editable
20
+ });
21
+ return editor;
22
+ };
23
+ /** NodeView 가 만든 탭바. */
24
+ const bar = (e) => e.view.dom.querySelector(".hce-tabs-bar");
25
+ const chips = (e) => Array.from(bar(e).querySelectorAll(".hce-tab-chip"));
26
+ const labels = (e) => chips(e).map((chip) => chip.querySelector(".hce-tab-chip-label")?.textContent ?? "");
27
+ const click = (el) => el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
28
+ const tabsNode = (e) => e.getJSON().content?.find((n) => n.type === "tabs");
29
+ /** 문서에 든 탭들의 제목(속성 그대로 — 기본값 대체 없음). */
30
+ const titles = (e) => (tabsNode(e)?.content ?? []).map((t) => String(t.attrs?.title ?? ""));
31
+ /** 탭마다 `[제목, 본문 글자]`. 어느 탭에 글이 들어갔는지 한눈에 보려고 쓴다. */
32
+ const titlesAndText = (e) => {
33
+ const out = [];
34
+ e.state.doc.forEach((node) => {
35
+ if (node.type.name !== "tabs")
36
+ return;
37
+ node.forEach((tab) => out.push([String(tab.attrs.title ?? ""), tab.textContent]));
38
+ });
39
+ return out;
40
+ };
41
+ /*
42
+ * ── 드래그용 가짜 좌표 ────────────────────────────────────────────────────
43
+ * happy-dom 은 레이아웃을 하지 않아 `getBoundingClientRect()` 가 전부 0 이다.
44
+ * 드롭 위치는 칩의 **중점**으로 정하므로, 칩마다 실제 화면과 같은 모양의 사각형을
45
+ * 물려 준다: i 번째 칩이 `[i*100, i*100+90]`, 탭바는 `[0, 400]`.
46
+ * ⚠️ `renderBar()` 가 칩을 새로 만들면 이 가짜 값도 날아간다 — 드래그 직전에 다시 건다.
47
+ */
48
+ const CHIP_W = 90;
49
+ const CHIP_GAP = 100;
50
+ const rect = (left, right, top = 0, bottom = 30) => ({
51
+ left,
52
+ right,
53
+ top,
54
+ bottom,
55
+ width: right - left,
56
+ height: bottom - top,
57
+ x: left,
58
+ y: top,
59
+ toJSON: () => ({})
60
+ });
61
+ const stubGeometry = (e) => {
62
+ bar(e).getBoundingClientRect = () => rect(0, 400);
63
+ chips(e).forEach((chip, i) => {
64
+ chip.getBoundingClientRect = () => rect(i * CHIP_GAP, i * CHIP_GAP + CHIP_W);
65
+ });
66
+ };
67
+ /** i 번째 칩의 가로 중앙 좌표. */
68
+ const chipCenter = (i) => i * CHIP_GAP + CHIP_W / 2;
69
+ const pointer = (el, type, x, y = 15, pointerId = 1) => el.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, pointerId }));
70
+ /**
71
+ * `from` 번째 칩을 `x` 로 끌어다 놓는다.
72
+ * `commit=false` 면 Esc 로 취소한다.
73
+ */
74
+ const drag = (e, from, x, y = 15, commit = true) => {
75
+ stubGeometry(e);
76
+ const chip = chips(e)[from];
77
+ const startX = chipCenter(from);
78
+ pointer(chip, "pointerdown", startX, y);
79
+ pointer(chip, "pointermove", startX + 12, y);
80
+ pointer(chip, "pointermove", x, y);
81
+ if (commit) {
82
+ pointer(chip, "pointerup", x, y);
83
+ }
84
+ else {
85
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
86
+ pointer(chip, "pointerup", x, y);
87
+ }
88
+ };
89
+ /*
90
+ * ⚠️ **마이크로태스크 하나로는 부족하다.** ProseMirror 가 자식을 다 그린 뒤에 활성 표시가
91
+ * 붙고, happy-dom 은 그 사이에 자기 작업을 태스크로 흘린다. 매크로태스크까지 비운다.
92
+ * (이 대기가 무한 루프를 잡아내는 그물이기도 하다 — ProseMirror 가 계속 다시 그리면
93
+ * 여기서 테스트가 통째로 멈춘다. 실제로 그렇게 잡았다.)
94
+ */
95
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
96
+ /** 활성 표시가 붙은 칩·패널의 인덱스. 둘이 어긋나면 화면과 탭바가 딴소리를 한다. */
97
+ const activeIndexes = (e) => {
98
+ const has = (list, cls) => list.findIndex((el) => el.classList.contains(cls));
99
+ const panels = e.view.dom.querySelector(".hce-tabs-panels");
100
+ return {
101
+ chip: has(chips(e), "is-active"),
102
+ panel: has(Array.from(panels?.children ?? []), "hce-tab-active")
103
+ };
104
+ };
105
+ afterEach(() => {
106
+ editor?.destroy();
107
+ editor = null;
108
+ host?.remove();
109
+ host = null;
110
+ });
111
+ describe("TabsBlock — 스키마", () => {
112
+ it("두 확장이 함께 등록된다", () => {
113
+ const e = make();
114
+ const names = e.extensionManager.extensions.map((x) => x.name);
115
+ expect(names).toContain("tabs");
116
+ expect(names).toContain("tab");
117
+ });
118
+ it("setTabs 는 기본 3개, 각 탭에 빈 문단 하나", () => {
119
+ const e = make();
120
+ e.commands.setTabs();
121
+ const node = tabsNode(e);
122
+ expect(node?.content).toHaveLength(3);
123
+ expect(node?.content?.[0]?.type).toBe("tab");
124
+ expect(node?.content?.[0]?.content?.[0]?.type).toBe("paragraph");
125
+ expect(titles(e)).toEqual(["탭 1", "탭 2", "탭 3"]);
126
+ });
127
+ it("setTabs(count) 로 개수를 정한다", () => {
128
+ const e = make();
129
+ e.commands.setTabs(2);
130
+ expect(titles(e)).toEqual(["탭 1", "탭 2"]);
131
+ });
132
+ it('div[data-type="tabs"] 저장본을 그대로 읽는다', () => {
133
+ const e = make('<div data-type="tabs">' +
134
+ '<div data-type="tab" data-tab-title="입력"><p>A</p></div>' +
135
+ '<div data-type="tab" data-tab-title="출력"><p>B</p></div>' +
136
+ "</div>");
137
+ const node = tabsNode(e);
138
+ expect(node?.content).toHaveLength(2);
139
+ expect(titles(e)).toEqual(["입력", "출력"]);
140
+ });
141
+ it("HTML 라운드트립 — 다시 읽어도 같은 문서다", () => {
142
+ const first = make();
143
+ first.commands.setTabs(3);
144
+ const html = first.getHTML();
145
+ expect(html).toContain('data-type="tabs"');
146
+ expect(html).toContain('data-type="tab"');
147
+ expect(html).toContain('data-tab-title="탭 1"');
148
+ const json = first.getJSON();
149
+ first.destroy();
150
+ host?.remove();
151
+ const second = make(html);
152
+ expect(second.getJSON()).toEqual(json);
153
+ });
154
+ it("⚠️ 살균이 data-tab-title 을 지우지 않는다", () => {
155
+ const e = make();
156
+ e.commands.setTabs(2);
157
+ const clean = sanitizeHtml(e.getHTML());
158
+ expect(clean).toContain('data-type="tabs"');
159
+ expect(clean).toContain('data-tab-title="탭 1"');
160
+ expect(clean).toContain('data-tab-title="탭 2"');
161
+ });
162
+ });
163
+ describe("TabsBlock — 탭바(NodeView)", () => {
164
+ it("탭 수만큼 칩을 그리고 첫 탭이 활성이다", async () => {
165
+ const e = make();
166
+ e.commands.setTabs(3);
167
+ await flush();
168
+ expect(labels(e)).toEqual(["탭 1", "탭 2", "탭 3"]);
169
+ expect(chips(e)[0].classList.contains("is-active")).toBe(true);
170
+ expect(chips(e)[1].classList.contains("is-active")).toBe(false);
171
+ });
172
+ it("칩을 누르면 활성 탭이 바뀐다", async () => {
173
+ const e = make();
174
+ e.commands.setTabs(3);
175
+ await flush();
176
+ click(chips(e)[2].querySelector(".hce-tab-chip-label"));
177
+ expect(chips(e)[2].classList.contains("is-active")).toBe(true);
178
+ expect(chips(e)[0].classList.contains("is-active")).toBe(false);
179
+ // 활성 표시는 contentDOM 의 자식에도 붙는다.
180
+ const panels = e.view.dom.querySelector(".hce-tabs-panels");
181
+ expect(panels.children[2].classList.contains("hce-tab-active")).toBe(true);
182
+ });
183
+ it("+ 를 누르면 탭이 늘고 새 탭이 활성이 된다", async () => {
184
+ const e = make();
185
+ e.commands.setTabs(2);
186
+ await flush();
187
+ click(bar(e).querySelector(".hce-tabs-add"));
188
+ expect(titles(e)).toEqual(["탭 1", "탭 2", "탭 3"]);
189
+ expect(labels(e)).toHaveLength(3);
190
+ expect(chips(e)[2].classList.contains("is-active")).toBe(true);
191
+ // 새 탭에도 빈 문단이 하나 들어간다.
192
+ const node = tabsNode(e);
193
+ expect(node?.content?.[2]?.content?.[0]?.type).toBe("paragraph");
194
+ });
195
+ it("× 를 누르면 그 탭이 지워진다", async () => {
196
+ const e = make();
197
+ e.commands.setTabs(3);
198
+ await flush();
199
+ click(chips(e)[1].querySelector(".hce-tab-chip-close"));
200
+ expect(titles(e)).toEqual(["탭 1", "탭 3"]);
201
+ expect(labels(e)).toEqual(["탭 1", "탭 3"]);
202
+ });
203
+ it("⚠️ 마지막 한 개는 지울 수 없다 — 버튼은 자리만 지킨다", async () => {
204
+ const e = make();
205
+ e.commands.setTabs(2);
206
+ await flush();
207
+ click(chips(e)[0].querySelector(".hce-tab-chip-close"));
208
+ expect(titles(e)).toHaveLength(1);
209
+ /*
210
+ * ⚠️ 버튼을 **없애지 않는다.** 없애면 칩 폭이 줄어 탭바가 옆으로 밀린다
211
+ * (호버로 나타났다 사라지던 시절의 클릭 빗나감과 같은 원인).
212
+ */
213
+ const close = chips(e)[0].querySelector(".hce-tab-chip-close");
214
+ expect(close).toBeTruthy();
215
+ expect(close.classList.contains("is-disabled")).toBe(true);
216
+ expect(close.disabled).toBe(true);
217
+ click(close);
218
+ expect(titles(e)).toHaveLength(1);
219
+ });
220
+ it("칩을 두 번 누르면 이름을 고칠 수 있다", async () => {
221
+ const e = make();
222
+ e.commands.setTabs(2);
223
+ await flush();
224
+ const label = chips(e)[0].querySelector(".hce-tab-chip-label");
225
+ label.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
226
+ const input = bar(e).querySelector(".hce-tab-title-input");
227
+ expect(input).toBeTruthy();
228
+ expect(input.value).toBe("탭 1");
229
+ input.value = "입력 형식";
230
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
231
+ expect(titles(e)).toEqual(["입력 형식", "탭 2"]);
232
+ expect(labels(e)).toEqual(["입력 형식", "탭 2"]);
233
+ expect(e.getHTML()).toContain('data-tab-title="입력 형식"');
234
+ });
235
+ it("Escape 로 이름 고치기를 취소한다", async () => {
236
+ const e = make();
237
+ e.commands.setTabs(2);
238
+ await flush();
239
+ const label = chips(e)[0].querySelector(".hce-tab-chip-label");
240
+ label.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
241
+ const input = bar(e).querySelector(".hce-tab-title-input");
242
+ input.value = "버린다";
243
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
244
+ expect(titles(e)).toEqual(["탭 1", "탭 2"]);
245
+ expect(bar(e).querySelector(".hce-tab-title-input")).toBeNull();
246
+ });
247
+ it("읽기 모드에는 추가·삭제·이름 고치기가 없고 전환만 된다", async () => {
248
+ const e = make('<div data-type="tabs">' +
249
+ '<div data-type="tab" data-tab-title="A"><p>a</p></div>' +
250
+ '<div data-type="tab" data-tab-title="B"><p>b</p></div>' +
251
+ "</div>", false);
252
+ await flush();
253
+ expect(labels(e)).toEqual(["A", "B"]);
254
+ expect(bar(e).querySelector(".hce-tabs-add")).toBeNull();
255
+ expect(bar(e).querySelector(".hce-tab-chip-close")).toBeNull();
256
+ const label = chips(e)[1].querySelector(".hce-tab-chip-label");
257
+ label.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
258
+ expect(bar(e).querySelector(".hce-tab-title-input")).toBeNull();
259
+ click(label);
260
+ expect(chips(e)[1].classList.contains("is-active")).toBe(true);
261
+ });
262
+ /*
263
+ * ── 아래 셋은 브라우저에서 실제로 터진 버그의 회귀 테스트다 ──────────────
264
+ * 처음 15개는 전부 통과하면서 이 셋을 놓쳤다. "칩이 그려진다"까지만 봤지
265
+ * **커서가 어디 있는지**와 **활성 클래스가 몇 번에 붙었는지**를 안 봤기 때문이다.
266
+ */
267
+ it("⚠️ 회귀 A: 삽입 직후 커서는 첫 탭 안이다", () => {
268
+ const e = make();
269
+ e.commands.setTabs(3);
270
+ const { $from } = e.state.selection;
271
+ const ancestors = [];
272
+ for (let d = $from.depth; d >= 0; d--)
273
+ ancestors.push($from.node(d).type.name);
274
+ expect(ancestors).toContain("tab");
275
+ // 첫 탭이어야 한다 — 마지막 탭이면 화면(활성=0)과 커서가 어긋난다.
276
+ const tabsPos = 0;
277
+ expect($from.pos).toBeGreaterThan(tabsPos + 1);
278
+ expect($from.pos).toBeLessThan(tabsPos + 1 + e.state.doc.child(0).child(0).nodeSize);
279
+ // 만들자마자 치면 첫 탭에 들어가야 한다.
280
+ e.commands.insertContent("AAA");
281
+ expect(titlesAndText(e)).toEqual([
282
+ ["탭 1", "AAA"],
283
+ ["탭 2", ""],
284
+ ["탭 3", ""]
285
+ ]);
286
+ });
287
+ it("⚠️ 회귀 B: 탭을 바꾼 직후 친 글자가 새 탭에 들어간다", async () => {
288
+ const e = make();
289
+ e.commands.setTabs(3);
290
+ await flush();
291
+ e.commands.insertContent("AAA");
292
+ click(chips(e)[1].querySelector(".hce-tab-chip-label"));
293
+ e.commands.insertContent("BBB");
294
+ // 예전엔 첫 글자가 탭 1로 샜다(탭1 "AAAB", 탭2 "BB").
295
+ expect(titlesAndText(e)).toEqual([
296
+ ["탭 1", "AAA"],
297
+ ["탭 2", "BBB"],
298
+ ["탭 3", ""]
299
+ ]);
300
+ });
301
+ it("⚠️ 회귀 B: 커서가 옮겨지는 순간 두 탭이 **모두** 보인다", async () => {
302
+ const e = make();
303
+ e.commands.setTabs(3);
304
+ await flush();
305
+ e.commands.insertContent("AAA");
306
+ const panels = e.view.dom.querySelector(".hce-tabs-panels");
307
+ let atMove = null;
308
+ const watch = ({ transaction }) => {
309
+ if (!transaction.selectionSet || atMove !== null)
310
+ return;
311
+ atMove = {
312
+ 대상: panels.children[1].classList.contains("hce-tab-active"),
313
+ 이전: panels.children[0].classList.contains("hce-tab-active")
314
+ };
315
+ };
316
+ e.on("transaction", watch);
317
+ click(chips(e)[1].querySelector(".hce-tab-chip-label"));
318
+ e.off("transaction", watch);
319
+ /*
320
+ * **보이게 → 옮기기 → 감추기** 순서의 못이다.
321
+ * - `대상`이 false 면 감춰진 곳으로 커서를 보낸 것이고,
322
+ * - `이전`이 false 면 커서가 아직 들어 있는 탭을 먼저 감춘 것이다.
323
+ * 둘 다 브라우저에서 "첫 글자가 이전 탭으로 새는" 증상으로 나타났다.
324
+ */
325
+ expect(atMove).toEqual({ 대상: true, 이전: true });
326
+ // 전환이 끝나면 물론 하나만 남는다.
327
+ expect(activeIndexes(e)).toEqual({ chip: 1, panel: 1 });
328
+ });
329
+ it("⚠️ 회귀 C: 구조가 바뀌어도 활성 클래스가 올바른 인덱스에 붙는다", async () => {
330
+ const e = make();
331
+ e.commands.setTabs(3);
332
+ await flush();
333
+ expect(activeIndexes(e)).toEqual({ chip: 0, panel: 0 });
334
+ // 탭 추가 — ProseMirror 가 자식 DOM 을 다시 그리면서 클래스를 날리던 자리.
335
+ click(bar(e).querySelector(".hce-tabs-add"));
336
+ await flush();
337
+ expect(activeIndexes(e)).toEqual({ chip: 3, panel: 3 });
338
+ // 이름 변경(속성만 바뀜)에서도 어긋나지 않는다.
339
+ const label = chips(e)[3].querySelector(".hce-tab-chip-label");
340
+ label.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
341
+ const input = bar(e).querySelector(".hce-tab-title-input");
342
+ input.value = "넷";
343
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
344
+ await flush();
345
+ expect(activeIndexes(e)).toEqual({ chip: 3, panel: 3 });
346
+ // 삭제로 줄어들 때도.
347
+ click(chips(e)[3].querySelector(".hce-tab-chip-close"));
348
+ await flush();
349
+ expect(activeIndexes(e)).toEqual({ chip: 2, panel: 2 });
350
+ });
351
+ it("⚠️ 탭바는 문서 내용이 아니다 — 칩 글자가 저장본에 새지 않는다", async () => {
352
+ const e = make();
353
+ e.commands.setTabs(2);
354
+ await flush();
355
+ const html = e.getHTML();
356
+ expect(html).not.toContain("hce-tabs-bar");
357
+ expect(html).not.toContain("hce-tab-chip");
358
+ // 문서에 남는 것은 탭 두 개와 그 안의 빈 문단뿐이다.
359
+ const node = tabsNode(e);
360
+ expect(node?.content).toHaveLength(2);
361
+ expect(node?.content?.[0]?.content).toHaveLength(1);
362
+ });
363
+ });
364
+ describe("TabsBlock — 칩 끌어서 순서 바꾸기", () => {
365
+ /** 탭마다 알아볼 수 있는 본문을 넣어 둔다. 내용이 제목만 따라오는지 보려면 필요하다. */
366
+ const withBodies = () => {
367
+ const e = make('<div data-type="tabs">' +
368
+ '<div data-type="tab" data-tab-title="하나"><p>본문 하나</p></div>' +
369
+ '<div data-type="tab" data-tab-title="둘"><p>본문 둘</p></div>' +
370
+ '<div data-type="tab" data-tab-title="셋"><p>본문 셋</p></div>' +
371
+ "</div>");
372
+ return e;
373
+ };
374
+ it("⚠️ 내용이 통째로 따라간다 — 제목만 바꾸는 구현이면 여기서 걸린다", async () => {
375
+ const e = withBodies();
376
+ await flush();
377
+ // 첫 칩을 맨 뒤로.
378
+ drag(e, 0, chipCenter(2) + 30);
379
+ expect(titlesAndText(e)).toEqual([
380
+ ["둘", "본문 둘"],
381
+ ["셋", "본문 셋"],
382
+ ["하나", "본문 하나"]
383
+ ]);
384
+ });
385
+ it("되돌리기 한 번이면 원래 순서로 — 트랜잭션은 하나다", async () => {
386
+ const e = withBodies();
387
+ await flush();
388
+ drag(e, 0, chipCenter(2) + 30);
389
+ expect(titles(e)).toEqual(["둘", "셋", "하나"]);
390
+ e.commands.undo();
391
+ expect(titles(e)).toEqual(["하나", "둘", "셋"]);
392
+ });
393
+ it("활성 탭을 옮기면 새 자리에서 그대로 활성이다", async () => {
394
+ const e = withBodies();
395
+ await flush();
396
+ expect(activeIndexes(e)).toEqual({ chip: 0, panel: 0 });
397
+ drag(e, 0, chipCenter(2) + 30); // 활성(0) → 맨 뒤
398
+ await flush();
399
+ expect(titles(e)).toEqual(["둘", "셋", "하나"]);
400
+ expect(activeIndexes(e)).toEqual({ chip: 2, panel: 2 });
401
+ });
402
+ it("활성보다 앞의 탭을 뒤로 보내면 활성이 한 칸 당겨진다", async () => {
403
+ const e = withBodies();
404
+ await flush();
405
+ click(chips(e)[1].querySelector(".hce-tab-chip-label")); // 활성 = 둘(1)
406
+ await flush();
407
+ drag(e, 0, chipCenter(2) + 30); // 하나(0) → 맨 뒤
408
+ await flush();
409
+ expect(titles(e)).toEqual(["둘", "셋", "하나"]);
410
+ expect(activeIndexes(e)).toEqual({ chip: 0, panel: 0 }); // 여전히 "둘"
411
+ });
412
+ it("활성보다 뒤의 탭을 앞으로 당기면 활성이 한 칸 밀린다", async () => {
413
+ const e = withBodies();
414
+ await flush();
415
+ click(chips(e)[1].querySelector(".hce-tab-chip-label")); // 활성 = 둘(1)
416
+ await flush();
417
+ drag(e, 2, chipCenter(0) - 10); // 셋(2) → 맨 앞
418
+ await flush();
419
+ expect(titles(e)).toEqual(["셋", "하나", "둘"]);
420
+ expect(activeIndexes(e)).toEqual({ chip: 2, panel: 2 }); // 여전히 "둘"
421
+ });
422
+ it("제자리에 놓으면 순서도 활성도 그대로다", async () => {
423
+ const e = withBodies();
424
+ await flush();
425
+ click(chips(e)[1].querySelector(".hce-tab-chip-label"));
426
+ await flush();
427
+ drag(e, 1, chipCenter(1) + 8);
428
+ await flush();
429
+ expect(titles(e)).toEqual(["하나", "둘", "셋"]);
430
+ expect(activeIndexes(e)).toEqual({ chip: 1, panel: 1 });
431
+ });
432
+ it("⚠️ 임계값 미만으로 움직이면 드래그가 아니라 전환 클릭이다", async () => {
433
+ const e = withBodies();
434
+ await flush();
435
+ stubGeometry(e);
436
+ const chip = chips(e)[2];
437
+ const start = chipCenter(2);
438
+ pointer(chip, "pointerdown", start);
439
+ pointer(chip, "pointermove", start + 3); // 5px 미만
440
+ pointer(chip, "pointerup", start + 3);
441
+ click(chip.querySelector(".hce-tab-chip-label"));
442
+ expect(titles(e)).toEqual(["하나", "둘", "셋"]); // 순서 그대로
443
+ expect(activeIndexes(e)).toEqual({ chip: 2, panel: 2 }); // 전환은 됐다
444
+ });
445
+ it("드래그로 끝나면 뒤따르는 클릭이 탭을 바꾸지 않는다", async () => {
446
+ const e = withBodies();
447
+ await flush();
448
+ drag(e, 2, chipCenter(0) - 10); // 셋 → 맨 앞
449
+ // 브라우저는 pointerup 뒤에 click 을 한 번 더 흘린다.
450
+ click(chips(e)[0].querySelector(".hce-tab-chip-label"));
451
+ await flush();
452
+ expect(titles(e)).toEqual(["셋", "하나", "둘"]);
453
+ expect(activeIndexes(e)).toEqual({ chip: 1, panel: 1 }); // 활성은 여전히 "하나"
454
+ });
455
+ it("Esc 로 취소하면 순서가 그대로다", async () => {
456
+ const e = withBodies();
457
+ await flush();
458
+ drag(e, 0, chipCenter(2) + 30, 15, false);
459
+ await flush();
460
+ expect(titles(e)).toEqual(["하나", "둘", "셋"]);
461
+ expect(activeIndexes(e)).toEqual({ chip: 0, panel: 0 });
462
+ });
463
+ it("탭바 밖에 놓으면 순서가 그대로다", async () => {
464
+ const e = withBodies();
465
+ await flush();
466
+ drag(e, 0, chipCenter(2), 400); // 탭바 아래로 한참 내려간 자리
467
+ await flush();
468
+ expect(titles(e)).toEqual(["하나", "둘", "셋"]);
469
+ });
470
+ it("끄는 동안 드롭될 자리가 보인다", async () => {
471
+ const e = withBodies();
472
+ await flush();
473
+ stubGeometry(e);
474
+ const chip = chips(e)[0];
475
+ pointer(chip, "pointerdown", chipCenter(0));
476
+ pointer(chip, "pointermove", chipCenter(0) + 12);
477
+ pointer(chip, "pointermove", chipCenter(1) + 10); // 둘의 중점보다 오른쪽 → 2번 앞
478
+ expect(bar(e).classList.contains("is-dragging")).toBe(true);
479
+ expect(chip.classList.contains("is-dragged")).toBe(true);
480
+ expect(chips(e)[2].classList.contains("is-drop-before")).toBe(true);
481
+ pointer(chip, "pointerup", chipCenter(1) + 10);
482
+ // 놓으면 표시가 사라진다.
483
+ expect(bar(e).classList.contains("is-dragging")).toBe(false);
484
+ expect(chips(e).some((c) => c.classList.contains("is-drop-before"))).toBe(false);
485
+ });
486
+ it("⚠️ 읽기 모드에는 드래그가 없다", async () => {
487
+ const e = make('<div data-type="tabs">' +
488
+ '<div data-type="tab" data-tab-title="하나"><p>a</p></div>' +
489
+ '<div data-type="tab" data-tab-title="둘"><p>b</p></div>' +
490
+ "</div>", false);
491
+ await flush();
492
+ drag(e, 0, chipCenter(1) + 30);
493
+ expect(titles(e)).toEqual(["하나", "둘"]);
494
+ expect(bar(e).classList.contains("is-dragging")).toBe(false);
495
+ });
496
+ it("⚠️ 삭제(×) 위에서 시작한 것은 드래그가 아니다", async () => {
497
+ const e = withBodies();
498
+ await flush();
499
+ stubGeometry(e);
500
+ const close = chips(e)[0].querySelector(".hce-tab-chip-close");
501
+ pointer(close, "pointerdown", chipCenter(0) + 30);
502
+ pointer(chips(e)[0], "pointermove", chipCenter(2));
503
+ expect(bar(e).classList.contains("is-dragging")).toBe(false);
504
+ pointer(chips(e)[0], "pointerup", chipCenter(2));
505
+ // 원래 하던 일(삭제)은 그대로 된다.
506
+ click(close);
507
+ expect(titles(e)).toEqual(["둘", "셋"]);
508
+ });
509
+ });
510
+ describe("TabsBlock — 회귀: ProseMirror 와의 줄다리기", () => {
511
+ /*
512
+ * ⚠️ 여기 두 개는 **`Tab` 의 `ignoreMutation` 이 빠지면** 터진다.
513
+ * 활성 표시 class 를 `contentDOM` 안의 요소에 쓰면 ProseMirror 가 그것을 "문서가 손으로
514
+ * 고쳐졌다"로 읽고 그 자리를 다시 그리고, 그러면 클래스가 사라져 우리가 또 붙이고…
515
+ * 가 끝없이 돈다. 실측으로 **저장된 탭 문서를 여는 것만으로** 재현됐다.
516
+ */
517
+ it("⚠️ 저장된 탭 문서를 열면 첫 탭이 곧바로 활성이다", async () => {
518
+ const e = make('<div data-type="tabs">' +
519
+ '<div data-type="tab" data-tab-title="하나"><p>a</p></div>' +
520
+ '<div data-type="tab" data-tab-title="둘"><p>b</p></div>' +
521
+ "</div>");
522
+ await flush();
523
+ // 예전엔 여기서 `panel: -1` — 어느 탭도 안 보이는 빈 상자였다.
524
+ expect(activeIndexes(e)).toEqual({ chip: 0, panel: 0 });
525
+ });
526
+ it("⚠️ 다시 그리기가 멈춘다 — 여러 번 기다려도 상태가 그대로다", async () => {
527
+ const e = make();
528
+ e.commands.setTabs(3);
529
+ await flush();
530
+ click(chips(e)[1].querySelector(".hce-tab-chip-label"));
531
+ await flush();
532
+ const first = activeIndexes(e);
533
+ await flush();
534
+ await flush();
535
+ // 루프가 돌면 위 대기에서 멈추거나, 여기서 값이 달라진다.
536
+ expect(activeIndexes(e)).toEqual(first);
537
+ expect(first).toEqual({ chip: 1, panel: 1 });
538
+ });
539
+ });
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export { CardBlock } from "./extensions/CardBlock";
15
15
  export type { CardBackgroundPrompt, CardBlockOptions } from "./extensions/CardBlock";
16
16
  export { Columns } from "./extensions/Columns";
17
17
  export { Column } from "./extensions/Column";
18
+ export { TabsBlock, Tab } from "./extensions/TabsBlock";
18
19
  export { MathInline, MathDisplay } from "./extensions/Math";
19
20
  export type { MathPrompt, MathOptions } from "./extensions/Math";
20
21
  export { sanitizeHtml, stripHtmlToExcerpt, transformLegacyHtml } from "./utils/sanitize";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC7E,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AACnF,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,MAAM,qCAAqC,CAAC;AAGjF,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AACnF,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,YAAY,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AACrF,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAC5D,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGjE,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACzF,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,YAAY,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACtE,OAAO,EAAE,EAAE,EAAE,MAAM,YAAY,CAAC;AAahC,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAG5D,YAAY,EACV,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,qBAAqB,EACrB,aAAa,GACd,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC7E,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AACnF,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,MAAM,qCAAqC,CAAC;AAGjF,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AACnF,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,YAAY,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AACrF,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAC5D,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGjE,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACzF,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,YAAY,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACtE,OAAO,EAAE,EAAE,EAAE,MAAM,YAAY,CAAC;AAahC,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAG5D,YAAY,EACV,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,qBAAqB,EACrB,aAAa,GACd,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export { MbusVideo } from "./extensions/MbusVideo";
15
15
  export { CardBlock } from "./extensions/CardBlock";
16
16
  export { Columns } from "./extensions/Columns";
17
17
  export { Column } from "./extensions/Column";
18
+ export { TabsBlock, Tab } from "./extensions/TabsBlock";
18
19
  export { MathInline, MathDisplay } from "./extensions/Math";
19
20
  // 유틸리티
20
21
  export { sanitizeHtml, stripHtmlToExcerpt, transformLegacyHtml } from "./utils/sanitize";