@teriusu/rich-editor 0.9.14 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) 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 +714 -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/sanitize.d.ts.map +1 -1
  33. package/dist/utils/sanitize.js +2 -0
  34. package/package.json +1 -1
@@ -0,0 +1,714 @@
1
+ import { Node, mergeAttributes } from "@tiptap/core";
2
+ import { Selection } from "@tiptap/pm/state";
3
+ /**
4
+ * 탭 블록 — 한 상자 안의 내용을 **탭으로 나눠** 한 번에 하나만 보여준다(노션의 그것).
5
+ *
6
+ * ## 구조는 `Columns` 를, 조작은 `CardBlock` 을 본떴다
7
+ *
8
+ * - **노드 두 개 쌍**(`tabs` 컨테이너 + `tab` 자식)은 `Columns`/`Column` 과 같은 모양이다.
9
+ * `tab` 은 `group: ""` 이라 혼자서는 어디에도 못 들어간다 — 반드시 `tabs` 안이다.
10
+ * - **NodeView 는 vanilla JS**, 컨트롤에 `data-tab-control` 을 달고 `stopEvent` 로
11
+ * ProseMirror 에게서 이벤트를 뺏는 것은 `CardBlock` 과 같은 방식이다
12
+ * (이 패키지의 NodeView 는 Svelte 를 쓰지 않는다 — `.claude/rules/rich-editor.md`).
13
+ *
14
+ * ## 활성 탭은 저장하지 않는다
15
+ *
16
+ * 지금 몇 번 탭을 보고 있는지는 **보는 사람의 화면 상태**지 문서의 내용이 아니다. 저장하면
17
+ * 내가 열어 둔 탭이 남의 문서에 박힌다. 그래서 NodeView 의 지역 변수로만 들고, 문서를 새로
18
+ * 열면 **언제나 첫 탭**이다.
19
+ *
20
+ * ## 저장 형식
21
+ *
22
+ * ```html
23
+ * <div data-type="tabs">
24
+ * <div data-type="tab" data-tab-title="탭 1"><p>…</p></div>
25
+ * <div data-type="tab" data-tab-title="탭 2"><p>…</p></div>
26
+ * </div>
27
+ * ```
28
+ *
29
+ * ⚠️ `data-tab-title` 은 `utils/sanitize.ts` 의 `ALLOWED_ATTRS.div` 에 **반드시** 있어야 한다.
30
+ * 빠지면 살균을 거칠 때 제목만 조용히 사라져 탭 이름이 전부 `탭 1`·`탭 2` 로 되돌아간다.
31
+ *
32
+ * ⚠️ 정적(살균) 렌더에는 탭바가 없다. 그때는 CSS 가 첫 탭만 남기고 나머지를 감춘다
33
+ * (`editor.css` 의 `:not(.hce-tabs-live)` 규칙). 그래야 에디터가 서기 전후로 높이가 튀지 않는다.
34
+ */
35
+ /** 이름을 따로 주지 않은 탭이 보여줄 기본 이름. */
36
+ const defaultTitle = (index) => `탭 ${index + 1}`;
37
+ /**
38
+ * 여기까지는 **클릭이다.** 손가락·마우스는 누를 때 몇 px 씩 흔들리므로, 이만큼 넘어야
39
+ * "끌었다"로 본다. 넘지 않고 떼면 평소대로 탭 전환이 되고 두 번 누르면 이름 고치기가 뜬다.
40
+ */
41
+ const DRAG_THRESHOLD = 5;
42
+ /**
43
+ * 클래스 값이 이미 원하는 상태면 **손대지 않는다.**
44
+ *
45
+ * ⚠️ `contentDOM` 안의 요소에 class 를 다시 쓰면 값이 같아도 ProseMirror 의 MutationObserver
46
+ * 가 깨어나 그 구간을 통째로 다시 읽는다. `classList.toggle(x, false)` 는 원래 없던 클래스에도
47
+ * 속성 쓰기를 일으키므로, 실제로 바뀔 때만 건드린다.
48
+ */
49
+ const toggleClass = (el, cls, on) => {
50
+ if (el.classList.contains(cls) === on)
51
+ return;
52
+ el.classList.toggle(cls, on);
53
+ };
54
+ /**
55
+ * 탭 하나. `tabs` 안에서만 산다(`group: ""`).
56
+ *
57
+ * `isolating: true` — 탭 경계 너머로 지우기·병합이 넘어가지 않게 한다. 없으면 탭 맨 앞에서
58
+ * Backspace 를 눌렀을 때 앞 탭의 내용과 합쳐진다.
59
+ */
60
+ export const Tab = Node.create({
61
+ name: "tab",
62
+ group: "",
63
+ content: "block+",
64
+ isolating: true,
65
+ addAttributes() {
66
+ return {
67
+ title: {
68
+ default: "",
69
+ parseHTML: (el) => el.getAttribute("data-tab-title") ?? "",
70
+ // 비어 있으면 속성을 안 쓴다(표시할 때 `탭 N` 로 떨어진다). `CardBlock` 과 같은 방식.
71
+ renderHTML: (attrs) => attrs.title ? { "data-tab-title": attrs.title } : {}
72
+ }
73
+ };
74
+ },
75
+ parseHTML() {
76
+ return [{ tag: 'div[data-type="tab"]' }];
77
+ },
78
+ renderHTML({ HTMLAttributes }) {
79
+ return ["div", mergeAttributes(HTMLAttributes, { "data-type": "tab" }), 0];
80
+ },
81
+ /**
82
+ * ⚠️ **이 NodeView 가 하는 일은 딱 하나 — `ignoreMutation` 이다. 빼면 무한 루프가 돈다.**
83
+ *
84
+ * 활성 표시(`hce-tab-active`)는 `tabs` NodeView 가 **밖에서** 이 요소에 붙인다. 그런데 이
85
+ * 요소는 `tabs` 의 `contentDOM` **안**이라 ProseMirror 가 소유한다. 기본 동작에서는 class
86
+ * 속성이 바뀌는 것을 "문서가 손으로 고쳐졌다"로 읽고 이 자리를 다시 그리는데, 다시 그리면
87
+ * 요소가 새로 만들어져 클래스가 사라지고 → 우리가 또 붙이고 → 또 다시 그리고…
88
+ * 가 끝없이 돈다.
89
+ *
90
+ * 실측: 이 훅이 없으면 탭이 든 문서를 열자마자 ProseMirror 가 자식 요소를 계속 갈아치워
91
+ * **활성 클래스가 어디에도 남지 않고**(화면에 아무 탭도 안 보인다), happy-dom 에서는
92
+ * 매크로태스크를 한 번만 흘려도 테스트가 통째로 멈췄다.
93
+ *
94
+ * `tab` 은 DOM 속성으로 관찰해야 할 것이 없으므로(제목은 노드 attr 로 관리한다)
95
+ * 속성 변화는 전부 무시해도 안전하다.
96
+ */
97
+ addNodeView() {
98
+ return () => {
99
+ const dom = document.createElement("div");
100
+ dom.setAttribute("data-type", "tab");
101
+ return {
102
+ dom,
103
+ contentDOM: dom,
104
+ ignoreMutation: (mutation) => mutation.type === "attributes"
105
+ };
106
+ };
107
+ }
108
+ });
109
+ export const TabsBlock = Node.create({
110
+ name: "tabs",
111
+ group: "block",
112
+ content: "tab+",
113
+ defining: true,
114
+ draggable: true,
115
+ parseHTML() {
116
+ return [{ tag: 'div[data-type="tabs"]' }];
117
+ },
118
+ renderHTML({ HTMLAttributes }) {
119
+ return ["div", mergeAttributes(HTMLAttributes, { "data-type": "tabs" }), 0];
120
+ },
121
+ addNodeView() {
122
+ return ({ node, editor, getPos }) => {
123
+ let currentNode = node;
124
+ /** 지금 보고 있는 탭. 문서에 저장하지 않는다(파일 첫머리 주석 참고). */
125
+ let active = 0;
126
+ /** 이름을 고치는 중인 탭. 이 동안에는 탭바를 다시 그리지 않는다(입력칸이 날아간다). */
127
+ let renaming = null;
128
+ /**
129
+ * 끌고 있는 칩. `renaming` 과 같은 이유로 **이 동안에도 탭바를 다시 그리지 않는다** —
130
+ * 다시 그리면 끌던 칩이 통째로 사라진다.
131
+ *
132
+ * `moved` 는 임계값을 넘겼는지다. 안 넘겼으면 드래그가 아니라 그냥 클릭이다.
133
+ * `drop` 은 "몇 번째 **앞**에 넣을지"(0…childCount). -1 이면 탭바 밖 = 취소.
134
+ */
135
+ let dragging = null;
136
+ /** 드래그로 끝난 포인터가 뒤이어 흘리는 `click` 을 한 번 삼킨다. */
137
+ let swallowClick = false;
138
+ /** 마지막으로 그린 탭바의 내용. 같으면 다시 그리지 않는다. */
139
+ let lastSignature = "";
140
+ const dom = document.createElement("div");
141
+ dom.setAttribute("data-type", "tabs");
142
+ dom.setAttribute("data-node-view-wrapper", "");
143
+ // ⚠️ `hce-tabs-live` 는 CSS 의 정적 폴백을 끄는 스위치다(`editor.css` 주석 참고).
144
+ dom.className = "hce-tabs hce-tabs-live";
145
+ /*
146
+ * ⚠️ **탭바는 `contentDOM` 바깥이다.** 안에 넣으면 ProseMirror 가 칩과 버튼을
147
+ * 노드의 내용으로 읽어 문서에 섞어 넣는다(그리고 지울 수도 없게 된다).
148
+ */
149
+ const bar = document.createElement("div");
150
+ bar.className = "hce-tabs-bar";
151
+ bar.contentEditable = "false";
152
+ // `stopEvent` 가 이 표시를 보고 이벤트를 가로챈다. `CardBlock` 의 `data-card-control` 과 같다.
153
+ bar.setAttribute("data-tab-control", "");
154
+ dom.appendChild(bar);
155
+ const panels = document.createElement("div");
156
+ panels.className = "hce-tabs-panels";
157
+ dom.appendChild(panels);
158
+ /**
159
+ * 탭바를 다시 그려야 하는지 판단하는 값 — 개수·이름·편집 가능 여부가 전부다.
160
+ *
161
+ * ⚠️ 이름 사이를 **탭 문자**로 끊는다. 그냥 이어 붙이면 ["ab","c"] 와 ["a","bc"] 가
162
+ * 같은 값이 되어 이름 변경을 놓친다(탭 이름에는 탭 문자가 못 들어간다).
163
+ * ⚠️ `isEditable` 도 넣는다. 호스트가 도중에 읽기↔편집을 뒤집으면 문서는 그대로라,
164
+ * 이 값이 없으면 `+`·`×` 가 나타나거나 사라지지 않는다.
165
+ */
166
+ const signature = (n) => {
167
+ const titles = [];
168
+ n.forEach((child) => titles.push(String(child.attrs.title ?? "")));
169
+ return `${editor.isEditable ? "w" : "r"} ${n.childCount} ${titles.join(" ")}`;
170
+ };
171
+ const titleOf = (index) => {
172
+ const raw = String(currentNode.child(index).attrs.title ?? "").trim();
173
+ return raw || defaultTitle(index);
174
+ };
175
+ /** `index` 번째 탭 노드의 문서상 위치. `tabs` 가 문서에서 빠졌으면 null. */
176
+ const childPos = (index) => {
177
+ const base = getPos();
178
+ if (base == null)
179
+ return null;
180
+ let pos = base + 1;
181
+ for (let i = 0; i < index; i++)
182
+ pos += currentNode.child(i).nodeSize;
183
+ return pos;
184
+ };
185
+ /** 활성 표시를 DOM 에 반영한다. */
186
+ const applyActive = () => {
187
+ for (let i = 0; i < panels.children.length; i++) {
188
+ toggleClass(panels.children[i], "hce-tab-active", i === active);
189
+ }
190
+ const chips = bar.querySelectorAll(".hce-tab-chip");
191
+ for (let i = 0; i < chips.length; i++) {
192
+ toggleClass(chips[i], "is-active", i === active);
193
+ }
194
+ };
195
+ /**
196
+ * ⚠️ **활성 표시는 두 번 붙여야 한다. 한 번으로는 조용히 빠진다.**
197
+ *
198
+ * ProseMirror 의 `CustomNodeViewDesc.update()` 는 우리 `update()` 를 **먼저** 부르고
199
+ * 그 다음에 `contentDOM` 의 자식을 다시 그린다. 그래서 그 자리에서 붙인 클래스는
200
+ * 곧이어 새로 만들어지는 자식 DOM 과 함께 사라진다 — 실측으로 탭을 하나 추가하면
201
+ * **어느 패널에도 클래스가 없는**(`-1`) 상태가 됐다. 자식이 다 그려진 뒤(같은
202
+ * 프레임의 마이크로태스크)에 한 번 더 붙인다. 눈에 보이는 지연은 없다.
203
+ *
204
+ * (다시 그려도 클래스가 **한 번만에 안착**하는 것은 `Tab` 의 `ignoreMutation` 덕이다.
205
+ * 그게 없으면 여기서 붙인 클래스가 다시 그리기를 부르고 끝없이 맴돈다 — 그쪽 주석 참고.)
206
+ */
207
+ const scheduleApplyActive = () => {
208
+ applyActive();
209
+ queueMicrotask(applyActive);
210
+ };
211
+ /**
212
+ * 커서를 지금 활성 탭 안으로 옮긴다.
213
+ *
214
+ * ⚠️ **`view.dispatch` + `view.focus()` 로는 부족했다.** 탭을 감추면 브라우저가
215
+ * DOM 선택을 제멋대로 옮기고, 그 뒤늦은 `selectionchange` 를 ProseMirror 가 읽어
216
+ * 방금 넣은 선택을 덮어썼다 — 실측 증상은 **탭을 바꾼 뒤 첫 글자만 이전 탭에
217
+ * 들어가는 것**이었다(1초를 기다려도 재현). TipTap 의 `focus` 커맨드는 다음 프레임에
218
+ * `view.focus()` 를 한 번 더 걸어 DOM 선택을 다시 못박으므로 그걸 쓴다.
219
+ */
220
+ const focusActiveTab = () => {
221
+ if (!editor.isEditable)
222
+ return;
223
+ const base = getPos();
224
+ if (base == null)
225
+ return;
226
+ const { state } = editor.view;
227
+ const { from } = state.selection;
228
+ // 커서가 이 탭 블록 밖이면 건드리지 않는다 — 탭을 눌렀다고 남의 자리를 뺏지 않는다.
229
+ if (from <= base || from >= base + currentNode.nodeSize)
230
+ return;
231
+ const start = childPos(active);
232
+ if (start == null)
233
+ return;
234
+ const end = start + currentNode.child(active).nodeSize;
235
+ // 이미 활성 탭 안이면 그대로 둔다.
236
+ if (from > start && from < end)
237
+ return;
238
+ try {
239
+ // `focus(pos)` 는 `TextSelection.create` 를 쓰므로 **인라인 자리**를 줘야 한다.
240
+ const target = Selection.near(state.doc.resolve(start + 1), 1).from;
241
+ editor.commands.focus(target);
242
+ }
243
+ catch {
244
+ // 위치가 어긋난 프레임 — 표시 전환은 이미 끝났으니 커서는 그냥 둔다.
245
+ }
246
+ };
247
+ const setActive = (index) => {
248
+ if (index < 0 || index >= currentNode.childCount)
249
+ return;
250
+ if (index === active)
251
+ return;
252
+ const previous = active;
253
+ active = index;
254
+ /*
255
+ * ⚠️ **보이게 → 옮기기 → 감추기** 순서를 지킨다.
256
+ * 감추기를 먼저 하면 커서가 `display: none` 안에 갇힌 채로 선택을 옮기게 되고,
257
+ * 브라우저는 안 보이는 곳에 캐럿을 놓지 못해 DOM 선택이 이전 탭에 남는다.
258
+ * 새 탭을 먼저 보이게 해 두면 옮길 자리가 이미 화면에 있다.
259
+ */
260
+ const target = panels.children[index];
261
+ if (target)
262
+ toggleClass(target, "hce-tab-active", true);
263
+ focusActiveTab();
264
+ const gone = panels.children[previous];
265
+ if (gone)
266
+ toggleClass(gone, "hce-tab-active", false);
267
+ scheduleApplyActive();
268
+ };
269
+ const setTitle = (index, title) => {
270
+ const pos = childPos(index);
271
+ if (pos == null)
272
+ return;
273
+ const child = currentNode.child(index);
274
+ if (String(child.attrs.title ?? "") === title)
275
+ return;
276
+ editor.view.dispatch(editor.view.state.tr.setNodeMarkup(pos, undefined, {
277
+ ...child.attrs,
278
+ title
279
+ }));
280
+ };
281
+ const addTab = () => {
282
+ const base = getPos();
283
+ if (base == null)
284
+ return;
285
+ const type = editor.schema.nodes.tab;
286
+ if (!type)
287
+ return;
288
+ const index = currentNode.childCount;
289
+ // `content: "block+"` 이라 `createAndFill` 이 빈 문단 하나를 채워 준다.
290
+ const created = type.createAndFill({ title: defaultTitle(index) });
291
+ if (!created)
292
+ return;
293
+ active = index;
294
+ // `tabs` 가 닫히기 직전 = 마지막 탭 뒤.
295
+ editor.view.dispatch(editor.view.state.tr.insert(base + currentNode.nodeSize - 1, created));
296
+ };
297
+ const removeTab = (index) => {
298
+ // ⚠️ 마지막 한 개는 못 지운다 — `content: "tab+"` 이라 빈 탭 블록은 스키마 위반이다.
299
+ if (currentNode.childCount <= 1)
300
+ return;
301
+ const from = childPos(index);
302
+ if (from == null)
303
+ return;
304
+ const to = from + currentNode.child(index).nodeSize;
305
+ // 지운 자리를 메우는 탭을 그대로 본다. 마지막을 지웠으면 한 칸 앞으로.
306
+ if (index < active || active >= currentNode.childCount - 1) {
307
+ active = Math.max(0, active - 1);
308
+ }
309
+ editor.view.dispatch(editor.view.state.tr.delete(from, to));
310
+ };
311
+ /**
312
+ * 탭 하나를 `to` **앞**으로 옮긴다(`to` 는 0…childCount, 옮기기 **전** 기준).
313
+ *
314
+ * ⚠️ 제목만 바꿔치기하지 않는다 — 노드를 통째로 들어 옮기므로 **내용이 같이 간다.**
315
+ * ⚠️ 트랜잭션 **하나**로 뺐다 넣는다. 두 번 dispatch 하면 되돌리기가 두 번 걸리고,
316
+ * 중간 상태에서 `update()` 가 한 번 더 돌아 탭바가 헛돈다.
317
+ */
318
+ const moveTab = (from, to) => {
319
+ // 제자리(자기 앞·자기 뒤)면 아무 일도 없다.
320
+ if (to === from || to === from + 1)
321
+ return;
322
+ if (from < 0 || from >= currentNode.childCount)
323
+ return;
324
+ if (to < 0 || to > currentNode.childCount)
325
+ return;
326
+ const fromPos = childPos(from);
327
+ const toPos = childPos(to);
328
+ if (fromPos == null || toPos == null)
329
+ return;
330
+ const moved = currentNode.child(from);
331
+ remapActive(from, to);
332
+ const tr = editor.view.state.tr;
333
+ tr.delete(fromPos, fromPos + moved.nodeSize);
334
+ // 뒤쪽 좌표는 삭제만큼 당겨진다 — 매핑을 태워야 자리가 맞는다.
335
+ tr.insert(tr.mapping.map(toPos), moved);
336
+ editor.view.dispatch(tr);
337
+ };
338
+ /**
339
+ * 옮긴 뒤에도 **보고 있던 그 탭**을 계속 본다. 인덱스가 아니라 노드를 따라간다.
340
+ *
341
+ * 옮긴 뒤 그 노드가 앉는 자리는 `to > from ? to - 1 : to`. 나머지는 두 단계로 센다 —
342
+ * ① 뺀 자리보다 뒤였으면 한 칸 당겨지고 ② 넣는 자리 이후면 한 칸 밀린다.
343
+ */
344
+ const remapActive = (from, to) => {
345
+ const landed = to > from ? to - 1 : to;
346
+ if (active === from) {
347
+ active = landed;
348
+ return;
349
+ }
350
+ let next = active > from ? active - 1 : active;
351
+ if (next >= landed)
352
+ next += 1;
353
+ active = next;
354
+ };
355
+ /** 지금 포인터 위치가 "몇 번째 앞"인지. 탭바 밖이면 -1(취소). */
356
+ const dropIndexAt = (x, y) => {
357
+ const rect = bar.getBoundingClientRect();
358
+ // 탭바 언저리까지는 봐 준다 — 경계에서 손이 조금 흔들렸다고 취소되면 답답하다.
359
+ const slack = 24;
360
+ if (x < rect.left - slack ||
361
+ x > rect.right + slack ||
362
+ y < rect.top - slack ||
363
+ y > rect.bottom + slack) {
364
+ return -1;
365
+ }
366
+ const list = bar.querySelectorAll(".hce-tab-chip");
367
+ for (let i = 0; i < list.length; i++) {
368
+ const r = list[i].getBoundingClientRect();
369
+ if (x < r.left + r.width / 2)
370
+ return i;
371
+ }
372
+ return list.length;
373
+ };
374
+ /** 드롭될 자리를 칩 사이의 세로 선으로 보여 준다. */
375
+ const markDrop = (drop) => {
376
+ const list = bar.querySelectorAll(".hce-tab-chip");
377
+ for (let i = 0; i < list.length; i++) {
378
+ toggleClass(list[i], "is-drop-before", drop === i);
379
+ toggleClass(list[i], "is-drop-after", drop === list.length && i === list.length - 1);
380
+ }
381
+ };
382
+ /**
383
+ * 드래그 중 Esc. 손은 버튼을 누르고 있어 키가 칩에 오지 않으므로 **문서에** 건다.
384
+ * 드래그가 끝나면 반드시 뗀다(`endDrag`).
385
+ */
386
+ const onEscape = (e) => {
387
+ if (e.key !== "Escape" || !dragging)
388
+ return;
389
+ e.preventDefault();
390
+ e.stopPropagation();
391
+ endDrag(false);
392
+ };
393
+ const endDrag = (commit) => {
394
+ const state = dragging;
395
+ if (!state)
396
+ return;
397
+ dragging = null;
398
+ detachDragListeners();
399
+ // ⚠️ 드래그 동안 꺼 뒀던 것을 되돌린다(이름 고치기와 같은 이유).
400
+ dom.draggable = true;
401
+ bar.classList.remove("is-dragging");
402
+ state.chip.classList.remove("is-dragged");
403
+ markDrop(-1);
404
+ // 임계값을 못 넘겼으면 드래그가 아니다. 뒤따르는 click 이 탭 전환을 한다.
405
+ if (!state.moved)
406
+ return;
407
+ swallowClick = true;
408
+ if (!commit || state.drop < 0)
409
+ return;
410
+ moveTab(state.index, state.drop);
411
+ };
412
+ /*
413
+ * 끄는 동안의 move·up 은 **문서에서** 받는다.
414
+ *
415
+ * ⚠️ **`setPointerCapture` 를 쓰지 않는다.** 칩에 캡처를 걸면 뒤따르는 호환 마우스
416
+ * 이벤트가 캡처 대상(칩)으로 **재타겟팅**된다. 그러면 칩 **안**의 라벨 버튼에 건
417
+ * 리스너를 건너뛰어, 더블클릭 이름 고치기가 통째로 죽는다 — 실측 로그에서 두 번째
418
+ * 클릭의 `target` 이 라벨이 아니라 칩으로 찍혔고 `dblclick` 은 아예 오지 않았다.
419
+ * 문서에 걸면 칩 밖으로 빠르게 끌어도 놓치지 않으므로 캡처가 필요 없다.
420
+ */
421
+ const onDragMove = (e) => {
422
+ if (!dragging || dragging.pointerId !== e.pointerId)
423
+ return;
424
+ if (!dragging.moved) {
425
+ // ⚠️ 임계값 전에는 드래그가 아니다 — 넘지 않고 떼면 전환 클릭이 살아야 한다.
426
+ const dx = Math.abs(e.clientX - dragging.startX);
427
+ const dy = Math.abs(e.clientY - dragging.startY);
428
+ if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD)
429
+ return;
430
+ dragging.moved = true;
431
+ // 끄는 동안에는 블록 통째로 끌기를 꺼 둔다.
432
+ dom.draggable = false;
433
+ bar.classList.add("is-dragging");
434
+ dragging.chip.classList.add("is-dragged");
435
+ }
436
+ e.preventDefault();
437
+ dragging.drop = dropIndexAt(e.clientX, e.clientY);
438
+ markDrop(dragging.drop);
439
+ };
440
+ const onDragUp = (e) => {
441
+ if (!dragging || dragging.pointerId !== e.pointerId)
442
+ return;
443
+ endDrag(true);
444
+ };
445
+ const onDragCancel = (e) => {
446
+ if (!dragging || dragging.pointerId !== e.pointerId)
447
+ return;
448
+ endDrag(false);
449
+ };
450
+ function attachDragListeners() {
451
+ document.addEventListener("pointermove", onDragMove, true);
452
+ document.addEventListener("pointerup", onDragUp, true);
453
+ document.addEventListener("pointercancel", onDragCancel, true);
454
+ document.addEventListener("keydown", onEscape, true);
455
+ }
456
+ function detachDragListeners() {
457
+ document.removeEventListener("pointermove", onDragMove, true);
458
+ document.removeEventListener("pointerup", onDragUp, true);
459
+ document.removeEventListener("pointercancel", onDragCancel, true);
460
+ document.removeEventListener("keydown", onEscape, true);
461
+ }
462
+ /** 칩을 두 번 누르면 그 자리에서 이름을 고친다. */
463
+ const startRename = (index, chip, label) => {
464
+ if (renaming !== null)
465
+ return;
466
+ renaming = index;
467
+ const input = document.createElement("input");
468
+ input.type = "text";
469
+ input.className = "hce-tab-title-input";
470
+ input.value = titleOf(index);
471
+ input.setAttribute("data-tab-control", "");
472
+ let done = false;
473
+ const finish = (commit) => {
474
+ if (done)
475
+ return;
476
+ done = true;
477
+ renaming = null;
478
+ dom.draggable = true;
479
+ const next = input.value.trim();
480
+ input.replaceWith(label);
481
+ if (commit)
482
+ setTitle(index, next);
483
+ };
484
+ input.addEventListener("keydown", (e) => {
485
+ if (e.key === "Enter") {
486
+ e.preventDefault();
487
+ finish(true);
488
+ }
489
+ else if (e.key === "Escape") {
490
+ e.preventDefault();
491
+ finish(false);
492
+ }
493
+ });
494
+ input.addEventListener("blur", () => finish(true));
495
+ /*
496
+ * ⚠️ 이 노드는 `draggable` 이라 ProseMirror 가 바깥 상자에 `draggable="true"` 를
497
+ * 건다. 그 안의 입력칸은 크롬에서 **드래그로 글자를 고를 수 없다** — 고치는 동안만
498
+ * 끈다(위 `finish` 에서 되돌린다).
499
+ */
500
+ input.addEventListener("mousedown", () => {
501
+ dom.draggable = false;
502
+ });
503
+ chip.replaceChild(input, label);
504
+ input.focus();
505
+ input.select();
506
+ };
507
+ const renderBar = () => {
508
+ bar.replaceChildren();
509
+ const editable = editor.isEditable;
510
+ for (let i = 0; i < currentNode.childCount; i++) {
511
+ const index = i;
512
+ const chip = document.createElement("div");
513
+ chip.className = "hce-tab-chip";
514
+ /*
515
+ * ── 끌어서 순서 바꾸기 ──────────────────────────────────────────
516
+ * ⚠️ **HTML5 드래그(`dragstart`/`drop`)를 쓰지 않는다.** 이 노드는
517
+ * `draggable: true` 라 ProseMirror 가 바깥 상자에 `draggable="true"` 를
518
+ * 걸어 두는데, 그러면 칩을 끄는 순간 블록 통째로 끌기가 먼저 잡아챈다
519
+ * (이름 입력칸에서 `dom.draggable = false` 우회를 해야 했던 그 문제).
520
+ * 포인터 이벤트로 직접 그린다.
521
+ */
522
+ if (editable) {
523
+ chip.addEventListener("pointerdown", (e) => {
524
+ if (e.button !== 0 || renaming !== null || dragging)
525
+ return;
526
+ const hit = e.target;
527
+ // ×(삭제) 위에서 시작한 것은 드래그가 아니다.
528
+ if (hit instanceof Element && hit.closest(".hce-tab-chip-close"))
529
+ return;
530
+ swallowClick = false;
531
+ dragging = {
532
+ index,
533
+ chip,
534
+ pointerId: e.pointerId,
535
+ startX: e.clientX,
536
+ startY: e.clientY,
537
+ moved: false,
538
+ drop: index
539
+ };
540
+ attachDragListeners();
541
+ });
542
+ }
543
+ const label = document.createElement("button");
544
+ label.type = "button";
545
+ label.className = "hce-tab-chip-label";
546
+ label.textContent = titleOf(index);
547
+ // 누르는 순간 에디터의 선택이 튀지 않게 한다(전환 판단은 `focusActiveTab` 이 한다).
548
+ label.addEventListener("mousedown", (e) => e.preventDefault());
549
+ label.addEventListener("click", (e) => {
550
+ e.preventDefault();
551
+ e.stopPropagation();
552
+ // 방금 끌어서 옮긴 거라면 전환하지 않는다.
553
+ if (swallowClick) {
554
+ swallowClick = false;
555
+ return;
556
+ }
557
+ setActive(index);
558
+ });
559
+ if (editable) {
560
+ label.title = "두 번 누르면 이름을 고칩니다";
561
+ label.addEventListener("dblclick", (e) => {
562
+ e.preventDefault();
563
+ e.stopPropagation();
564
+ startRename(index, chip, label);
565
+ });
566
+ }
567
+ chip.appendChild(label);
568
+ /*
569
+ * 읽기 모드에는 삭제가 없다.
570
+ *
571
+ * ⚠️ 편집 모드에서는 **마지막 한 개일 때도 자리는 그대로 만든다.** 예전엔
572
+ * `display: none` 으로 감췄다가 호버할 때 나타나게 했는데, 그때마다 칩 폭이
573
+ * 늘어 **`+` 와 뒤쪽 칩이 커서 밑에서 옆으로 밀렸다**(실측: 겨냥한 `+` 가
574
+ * 18px 옮겨져 클릭이 빗나갔다). 지금은 늘 자리를 차지하고 `visibility` 로만
575
+ * 보였다 안 보였다 한다 — 지울 수 없는 칩은 `is-disabled` 로 영영 감춘다.
576
+ */
577
+ if (editable) {
578
+ const deletable = currentNode.childCount > 1;
579
+ const close = document.createElement("button");
580
+ close.type = "button";
581
+ close.className = deletable
582
+ ? "hce-tab-chip-close"
583
+ : "hce-tab-chip-close is-disabled";
584
+ close.setAttribute("aria-label", `${titleOf(index)} 삭제`);
585
+ close.textContent = "×";
586
+ if (deletable) {
587
+ close.addEventListener("mousedown", (e) => e.preventDefault());
588
+ close.addEventListener("click", (e) => {
589
+ e.preventDefault();
590
+ e.stopPropagation();
591
+ removeTab(index);
592
+ });
593
+ }
594
+ else {
595
+ close.disabled = true;
596
+ close.tabIndex = -1;
597
+ close.setAttribute("aria-hidden", "true");
598
+ }
599
+ chip.appendChild(close);
600
+ }
601
+ bar.appendChild(chip);
602
+ }
603
+ if (editable) {
604
+ const add = document.createElement("button");
605
+ add.type = "button";
606
+ add.className = "hce-tabs-add";
607
+ add.setAttribute("aria-label", "탭 추가");
608
+ add.title = "탭 추가";
609
+ add.textContent = "+";
610
+ add.addEventListener("mousedown", (e) => e.preventDefault());
611
+ add.addEventListener("click", (e) => {
612
+ e.preventDefault();
613
+ e.stopPropagation();
614
+ addTab();
615
+ });
616
+ bar.appendChild(add);
617
+ }
618
+ };
619
+ lastSignature = signature(node);
620
+ renderBar();
621
+ /*
622
+ * ⚠️ 지금은 `panels` 가 **비어 있다.** ProseMirror 는 이 함수가 돌려준 뒤에야
623
+ * `contentDOM` 을 채우므로, 첫 활성 표시는 그 다음 마이크로태스크에서 붙는다
624
+ * (같은 프레임 안이라 화면에는 깜빡임이 없다).
625
+ */
626
+ scheduleApplyActive();
627
+ return {
628
+ dom,
629
+ contentDOM: panels,
630
+ update: (updated) => {
631
+ if (updated.type.name !== "tabs")
632
+ return false;
633
+ currentNode = updated;
634
+ // 탭이 줄었으면 인덱스를 범위 안으로 당긴다.
635
+ if (active >= updated.childCount) {
636
+ active = Math.max(0, updated.childCount - 1);
637
+ }
638
+ const next = signature(updated);
639
+ // ⚠️ 이름을 고치는 중이거나 칩을 끄는 중이면 다시 그리지 않는다 —
640
+ // 그리는 순간 입력칸이나 끌던 칩이 통째로 사라진다.
641
+ if (renaming === null && dragging === null && next !== lastSignature) {
642
+ lastSignature = next;
643
+ renderBar();
644
+ }
645
+ scheduleApplyActive();
646
+ return true;
647
+ },
648
+ destroy: () => {
649
+ // 드래그 도중에 노드가 사라질 수 있다 — 문서에 건 리스너를 남기지 않는다.
650
+ detachDragListeners();
651
+ },
652
+ // 탭바 위의 이벤트는 ProseMirror 가 가로채면 안 된다.
653
+ stopEvent: (event) => {
654
+ const target = event.target;
655
+ return target instanceof Element && !!target.closest("[data-tab-control]");
656
+ },
657
+ /*
658
+ * ⚠️ 탭바는 우리가 직접 짓고 부수는 DOM 이다. 여기서 걸러 내지 않으면 칩 하나를
659
+ * 그릴 때마다 ProseMirror 가 "문서가 바뀌었나" 하고 이 구간을 다시 읽는다.
660
+ */
661
+ ignoreMutation: (mutation) => {
662
+ if (!panels.contains(mutation.target))
663
+ return true;
664
+ // 활성 표시 클래스는 우리가 붙였다 뗀 것이지 문서의 변화가 아니다.
665
+ return (mutation.type === "attributes" && mutation.attributeName === "class");
666
+ }
667
+ };
668
+ };
669
+ },
670
+ addCommands() {
671
+ return {
672
+ setTabs: (count = 3) => ({ chain }) => {
673
+ const total = Math.max(1, Math.floor(count));
674
+ return (chain()
675
+ .insertContent({
676
+ type: this.name,
677
+ content: Array.from({ length: total }, (_unused, i) => ({
678
+ type: "tab",
679
+ attrs: { title: defaultTitle(i) },
680
+ content: [{ type: "paragraph" }]
681
+ }))
682
+ })
683
+ /*
684
+ * ⚠️ **커서를 첫 탭 안에 넣어 준다.** `insertContent` 가 놓아 주는 자리는
685
+ * 마지막 탭(또는 블록 뒤 문단)인데, 화면에 보이는 것은 첫 탭이라
686
+ * 만들자마자 글자를 치면 **세 탭이 전부 빈 채로 남고 글자는 엉뚱한
687
+ * 곳에 들어갔다**(실측). 넣은 `tabs` 를 찾아 첫 탭의 문단으로 옮긴다.
688
+ */
689
+ .command(({ tr, dispatch }) => {
690
+ if (!dispatch)
691
+ return true;
692
+ const at = tr.selection.from;
693
+ let found = -1;
694
+ tr.doc.nodesBetween(Math.max(0, at - 1), Math.min(tr.doc.content.size, at + 1), (candidate, pos) => {
695
+ if (found === -1 && candidate.type.name === "tabs")
696
+ found = pos;
697
+ return found === -1;
698
+ });
699
+ if (found === -1)
700
+ return true;
701
+ try {
702
+ // `found + 1` = 첫 탭의 시작. `near(…, 1)` 이 그 안 첫 글자 자리를 찾는다.
703
+ tr.setSelection(Selection.near(tr.doc.resolve(found + 1), 1));
704
+ }
705
+ catch {
706
+ // 자리를 못 찾으면 `insertContent` 가 놓은 곳에 그대로 둔다.
707
+ }
708
+ return true;
709
+ })
710
+ .run());
711
+ }
712
+ };
713
+ }
714
+ });