@teriusu/rich-editor 0.10.8 → 0.11.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 (147) hide show
  1. package/dist/components/BubbleToolbar.svelte +40 -36
  2. package/dist/components/BubbleToolbar.svelte.d.ts +3 -0
  3. package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -1
  4. package/dist/components/EmojiSuggestionMenu.svelte +291 -0
  5. package/dist/components/EmojiSuggestionMenu.svelte.d.ts +9 -0
  6. package/dist/components/EmojiSuggestionMenu.svelte.d.ts.map +1 -0
  7. package/dist/components/FixedToolbar.svelte +53 -47
  8. package/dist/components/FixedToolbar.svelte.d.ts +3 -0
  9. package/dist/components/FixedToolbar.svelte.d.ts.map +1 -1
  10. package/dist/components/FloatingHelper.svelte +219 -0
  11. package/dist/components/FloatingHelper.svelte.d.ts +14 -0
  12. package/dist/components/FloatingHelper.svelte.d.ts.map +1 -0
  13. package/dist/components/InputModal.svelte +7 -3
  14. package/dist/components/InputModal.svelte.d.ts +3 -0
  15. package/dist/components/InputModal.svelte.d.ts.map +1 -1
  16. package/dist/components/MathModal.svelte +33 -29
  17. package/dist/components/MathModal.svelte.d.ts +3 -0
  18. package/dist/components/MathModal.svelte.d.ts.map +1 -1
  19. package/dist/components/MediaPickerModal.svelte +7 -3
  20. package/dist/components/MediaPickerModal.svelte.d.ts +3 -0
  21. package/dist/components/MediaPickerModal.svelte.d.ts.map +1 -1
  22. package/dist/components/SlashCommandMenu.svelte +88 -72
  23. package/dist/components/SlashCommandMenu.svelte.d.ts +3 -0
  24. package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -1
  25. package/dist/components/TableBubbleMenu.svelte +41 -33
  26. package/dist/components/TableBubbleMenu.svelte.d.ts +3 -0
  27. package/dist/components/TableBubbleMenu.svelte.d.ts.map +1 -1
  28. package/dist/components/TipTapEditor.svelte +239 -127
  29. package/dist/components/TipTapEditor.svelte.d.ts +7 -0
  30. package/dist/components/TipTapEditor.svelte.d.ts.map +1 -1
  31. package/dist/extensions/CardBlock.d.ts.map +1 -1
  32. package/dist/extensions/CardBlock.js +6 -4
  33. package/dist/extensions/FileAttachment.d.ts.map +1 -1
  34. package/dist/extensions/FileAttachment.js +2 -1
  35. package/dist/extensions/FixedDetails.d.ts.map +1 -1
  36. package/dist/extensions/FixedDetails.js +5 -3
  37. package/dist/extensions/LegacyBlock.d.ts.map +1 -1
  38. package/dist/extensions/LegacyBlock.js +8 -6
  39. package/dist/extensions/Math.d.ts.map +1 -1
  40. package/dist/extensions/Math.js +5 -2
  41. package/dist/extensions/MbusVideo.d.ts.map +1 -1
  42. package/dist/extensions/MbusVideo.js +73 -9
  43. package/dist/extensions/MediaToolbar.d.ts +41 -0
  44. package/dist/extensions/MediaToolbar.d.ts.map +1 -0
  45. package/dist/extensions/MediaToolbar.js +196 -0
  46. package/dist/extensions/MediaToolbar.test.d.ts +2 -0
  47. package/dist/extensions/MediaToolbar.test.d.ts.map +1 -0
  48. package/dist/extensions/MediaToolbar.test.js +231 -0
  49. package/dist/extensions/OrderedListMarker.d.ts +2 -0
  50. package/dist/extensions/OrderedListMarker.d.ts.map +1 -0
  51. package/dist/extensions/OrderedListMarker.js +114 -0
  52. package/dist/extensions/OrderedListMarker.test.d.ts +2 -0
  53. package/dist/extensions/OrderedListMarker.test.d.ts.map +1 -0
  54. package/dist/extensions/OrderedListMarker.test.js +176 -0
  55. package/dist/extensions/PdfBlock.d.ts.map +1 -1
  56. package/dist/extensions/PdfBlock.js +25 -23
  57. package/dist/extensions/ResizableImage.d.ts.map +1 -1
  58. package/dist/extensions/ResizableImage.js +20 -1
  59. package/dist/extensions/TableKeymap.d.ts +16 -0
  60. package/dist/extensions/TableKeymap.d.ts.map +1 -0
  61. package/dist/extensions/TableKeymap.js +97 -0
  62. package/dist/extensions/TableKeymap.test.d.ts +2 -0
  63. package/dist/extensions/TableKeymap.test.d.ts.map +1 -0
  64. package/dist/extensions/TableKeymap.test.js +147 -0
  65. package/dist/extensions/TabsBlock.d.ts.map +1 -1
  66. package/dist/extensions/TabsBlock.js +12 -9
  67. package/dist/extensions/TiptapMidibus.d.ts.map +1 -1
  68. package/dist/extensions/TiptapMidibus.js +60 -6
  69. package/dist/extensions/UploadSkeleton.d.ts +61 -0
  70. package/dist/extensions/UploadSkeleton.d.ts.map +1 -0
  71. package/dist/extensions/UploadSkeleton.js +233 -0
  72. package/dist/extensions/UploadSkeleton.test.d.ts +2 -0
  73. package/dist/extensions/UploadSkeleton.test.d.ts.map +1 -0
  74. package/dist/extensions/UploadSkeleton.test.js +128 -0
  75. package/dist/extensions/VideoEmbed.d.ts +8 -0
  76. package/dist/extensions/VideoEmbed.d.ts.map +1 -1
  77. package/dist/extensions/VideoEmbed.js +226 -45
  78. package/dist/extensions/VideoEmbed.test.js +131 -2
  79. package/dist/i18n/en.d.ts +3 -0
  80. package/dist/i18n/en.d.ts.map +1 -0
  81. package/dist/i18n/en.js +191 -0
  82. package/dist/i18n/es.d.ts +3 -0
  83. package/dist/i18n/es.d.ts.map +1 -0
  84. package/dist/i18n/es.js +191 -0
  85. package/dist/i18n/index.d.ts +43 -0
  86. package/dist/i18n/index.d.ts.map +1 -0
  87. package/dist/i18n/index.js +109 -0
  88. package/dist/i18n/index.test.d.ts +2 -0
  89. package/dist/i18n/index.test.d.ts.map +1 -0
  90. package/dist/i18n/index.test.js +173 -0
  91. package/dist/i18n/ja.d.ts +3 -0
  92. package/dist/i18n/ja.d.ts.map +1 -0
  93. package/dist/i18n/ja.js +191 -0
  94. package/dist/i18n/ko.d.ts +198 -0
  95. package/dist/i18n/ko.d.ts.map +1 -0
  96. package/dist/i18n/ko.js +215 -0
  97. package/dist/i18n/zh-hans.d.ts +3 -0
  98. package/dist/i18n/zh-hans.d.ts.map +1 -0
  99. package/dist/i18n/zh-hans.js +191 -0
  100. package/dist/i18n/zh-hant.d.ts +3 -0
  101. package/dist/i18n/zh-hant.d.ts.map +1 -0
  102. package/dist/i18n/zh-hant.js +192 -0
  103. package/dist/index.d.ts +7 -1
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +13 -1
  106. package/dist/static/highlight.d.ts +13 -0
  107. package/dist/static/highlight.d.ts.map +1 -0
  108. package/dist/static/highlight.js +99 -0
  109. package/dist/static/index.d.ts +33 -0
  110. package/dist/static/index.d.ts.map +1 -0
  111. package/dist/static/index.js +367 -0
  112. package/dist/static/index.test.d.ts +2 -0
  113. package/dist/static/index.test.d.ts.map +1 -0
  114. package/dist/static/index.test.js +194 -0
  115. package/dist/static/tokenizer.d.ts +52 -0
  116. package/dist/static/tokenizer.d.ts.map +1 -0
  117. package/dist/static/tokenizer.js +289 -0
  118. package/dist/styles/editor.css +1 -1
  119. package/dist/utils/emoji-data.d.ts +15 -0
  120. package/dist/utils/emoji-suggestion.d.ts +48 -0
  121. package/dist/utils/emoji-suggestion.d.ts.map +1 -0
  122. package/dist/utils/emoji-suggestion.js +51 -0
  123. package/dist/utils/emoji-suggestion.test.d.ts +2 -0
  124. package/dist/utils/emoji-suggestion.test.d.ts.map +1 -0
  125. package/dist/utils/emoji-suggestion.test.js +117 -0
  126. package/dist/utils/floating-helper.d.ts +60 -0
  127. package/dist/utils/floating-helper.d.ts.map +1 -0
  128. package/dist/utils/floating-helper.js +63 -0
  129. package/dist/utils/floating-helper.test.d.ts +2 -0
  130. package/dist/utils/floating-helper.test.d.ts.map +1 -0
  131. package/dist/utils/floating-helper.test.js +141 -0
  132. package/dist/utils/guard-vscode-paste.d.ts +19 -0
  133. package/dist/utils/guard-vscode-paste.d.ts.map +1 -0
  134. package/dist/utils/guard-vscode-paste.js +46 -0
  135. package/dist/utils/guard-vscode-paste.test.d.ts +2 -0
  136. package/dist/utils/guard-vscode-paste.test.d.ts.map +1 -0
  137. package/dist/utils/guard-vscode-paste.test.js +86 -0
  138. package/dist/utils/media-size.d.ts +29 -0
  139. package/dist/utils/media-size.d.ts.map +1 -0
  140. package/dist/utils/media-size.js +90 -0
  141. package/dist/utils/resize.d.ts +12 -0
  142. package/dist/utils/resize.d.ts.map +1 -1
  143. package/dist/utils/resize.js +5 -5
  144. package/dist/utils/sanitize.d.ts +2 -0
  145. package/dist/utils/sanitize.d.ts.map +1 -1
  146. package/dist/utils/sanitize.js +28 -3
  147. package/package.json +8 -1
@@ -27,6 +27,9 @@
27
27
  * 자리표시자에는 플레이어 새 탭 링크를 둔다 — 비로그인 prod 가 쓰는 것과 같은 주소다.
28
28
  */
29
29
  import { Node, mergeAttributes } from "@tiptap/core";
30
+ import { attachResize } from "../utils/resize";
31
+ import { normalizeMediaHeight } from "../utils/media-size";
32
+ import { getEditorTranslator } from "../i18n";
30
33
  /**
31
34
  * ⚠️ `on*` 만 걸러내고 나머지는 손대지 않는다. prod 가 `data-resize-*`·`data-bubble-menu`
32
35
  * 같은 것을 붙여 저장해도 여기서 살아남아야 왕복이 성립한다 — 아는 속성만 남기면
@@ -97,7 +100,8 @@ export const TiptapMidibus = Node.create({
97
100
  ];
98
101
  },
99
102
  addNodeView() {
100
- return ({ node, editor }) => {
103
+ return ({ node, editor, getPos }) => {
104
+ const t = getEditorTranslator(editor);
101
105
  const dom = document.createElement("div");
102
106
  dom.setAttribute("data-type", "tiptapMidibus");
103
107
  dom.setAttribute("data-node-view-wrapper", "");
@@ -137,20 +141,60 @@ export const TiptapMidibus = Node.create({
137
141
  const videoId = String(node.attrs.id ?? "").trim();
138
142
  const startRaw = Number(node.attrs.start ?? 0);
139
143
  const start = Number.isFinite(startRaw) ? Math.max(0, Math.floor(startRaw)) : 0;
144
+ /*
145
+ * 자리표시자도 저장된 `height` 를 따른다(prod 기본 600). `padding-top` 대신
146
+ * `aspect-ratio` 를 쓰는 이유는 `VideoEmbed` 주석 참조 — 높이 드래그가 인라인
147
+ * `height` 하나로 먹게 하기 위해서다.
148
+ */
140
149
  const aspect = document.createElement("div");
141
150
  aspect.style.cssText =
142
- "position:relative;width:100%;padding-top:56.25%;background:#0b1020;border-radius:8px;overflow:hidden;";
151
+ "position:relative;width:100%;aspect-ratio:16 / 9;background:#0b1020;border-radius:8px;overflow:hidden;";
152
+ const height = normalizeMediaHeight(node.attrs.height);
153
+ if (height != null) {
154
+ aspect.style.height = `${height}px`;
155
+ aspect.style.removeProperty("aspect-ratio");
156
+ }
143
157
  dom.appendChild(aspect);
158
+ /*
159
+ * 높이 드래그(main 은 `tiptap-midibus` 에 정확히 이것 하나만 준다 — 비율·정렬
160
+ * 칩 없음). ⚠️ 이 노드는 `renderHTML` 이 `rawAttrs` 를 그대로 되뱉으므로,
161
+ * `height` 만 바꾸면 저장본에는 **옛 높이가 남는다.** 커밋 때 `rawAttrs` 사본의
162
+ * `height` 도 함께 맞춘다 — 다른 속성·순서는 그대로라 바이트 보존이 유지된다.
163
+ */
164
+ let detachResize = null;
165
+ if (editor.isEditable) {
166
+ detachResize = attachResize({
167
+ dom: aspect,
168
+ handleParent: dom,
169
+ editor,
170
+ getPos: () => (typeof getPos === "function" ? getPos() : undefined),
171
+ getNode: () => node,
172
+ axis: "y",
173
+ attr: "height",
174
+ min: 160,
175
+ max: 1600,
176
+ label: t("lectureResizeHeight"),
177
+ buildAttrs: (current, value) => {
178
+ const nextHeight = String(Math.round(value));
179
+ const raw = current.attrs.rawAttrs;
180
+ return {
181
+ ...current.attrs,
182
+ height: nextHeight,
183
+ rawAttrs: raw ? { ...raw, height: nextHeight } : raw
184
+ };
185
+ }
186
+ });
187
+ }
144
188
  const poster = document.createElement("div");
145
189
  poster.style.cssText =
146
190
  "position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px;text-align:center;color:#c7cbd4;";
147
191
  aspect.appendChild(poster);
148
192
  const label = document.createElement("span");
149
- label.textContent = "강의 영상";
193
+ label.textContent = t("lectureVideo");
150
194
  label.style.cssText = "font-size:13px;font-weight:600;line-height:1.5;";
151
195
  poster.appendChild(label);
152
196
  const hint = document.createElement("span");
153
- hint.textContent = videoId ? videoId : "영상 주소가 비어 있습니다";
197
+ hint.textContent = videoId ? videoId : t("lectureEmptySrc");
154
198
  hint.style.cssText = "font-size:12px;line-height:1.5;opacity:0.7;word-break:break-all;";
155
199
  poster.appendChild(hint);
156
200
  if (videoId) {
@@ -158,12 +202,22 @@ export const TiptapMidibus = Node.create({
158
202
  open.href = `${this.options.playerBaseUrl}/${videoId}?start=${start}&volume=50`;
159
203
  open.target = "_blank";
160
204
  open.rel = "noopener noreferrer";
161
- open.textContent = "새 탭에서 보기";
205
+ open.textContent = t("openInNewTab");
162
206
  open.style.cssText =
163
207
  "font-size:13px;font-weight:600;color:#fff;background:rgba(255,255,255,0.16);border-radius:6px;padding:6px 14px;text-decoration:none;";
164
208
  poster.appendChild(open);
165
209
  }
166
- return { dom };
210
+ /*
211
+ * `update` 는 두지 않는다 — 속성이 바뀌면 노드뷰를 새로 세운다(렌더러 주입
212
+ * 경로와 같은 이유). 그래서 위 `getNode` 가 클로저의 `node` 를 그대로 써도
213
+ * 낡을 새가 없다: 낡아지는 순간 이 뷰 자체가 버려진다.
214
+ */
215
+ return {
216
+ dom,
217
+ destroy: () => {
218
+ detachResize?.();
219
+ }
220
+ };
167
221
  };
168
222
  },
169
223
  addCommands() {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 업로드 자리표시자 — 업로드가 도는 동안 **삽입 지점에** 서는 회색 스켈레톤 블록.
3
+ * (main 정올 `plugin/upload/skeleton` 참조 — Svelte NodeView 를 vanilla 로 재작성.)
4
+ *
5
+ * 전체 화면 오버레이(`업로드 중...`)와 달리 문서 안 제자리에 서므로, 업로드가 도는
6
+ * 동안에도 커서를 옮기고 다른 곳을 편집할 수 있다. 흐름은 세 동작이 전부다:
7
+ *
8
+ * 1. `insertUploadSkeleton(editor, { kind })` → 스켈레톤이 서고 핸들을 돌려받는다
9
+ * 2. 성공: `handle.replaceWith({ type: "image", attrs: { src } })` — 그 자리가 실제 노드로
10
+ * 3. 실패: `handle.remove()` — 흔적 없이 사라진다
11
+ *
12
+ * 핸들은 위치가 아니라 **`uploadId` 로 노드를 다시 찾는다.** 업로드가 도는 몇 초 사이
13
+ * 사용자가 위쪽에 글을 쓰면 위치가 밀리는데, id 로 찾으면 어디로 밀렸든 맞는 자리를
14
+ * 바꾼다. 그 사이 사용자가 스켈레톤을 지웠으면 조용히 `false` 를 돌려준다.
15
+ *
16
+ * ⚠️ **저장 HTML 에 남으면 안 된다.** 스켈레톤은 "업로드 중" 이라는 화면 상태지 내용이
17
+ * 아니다. 세 겹으로 막는다 — ① `TipTapEditor` 가 `onChange` 로 내보내기 전에
18
+ * `stripUploadSkeletonHtml` 로 걷어내고, ② 문서를 열 때 `transformLegacyHtml` 이 같은
19
+ * 태그를 지우며(과거에 새어 나간 것 정리), ③ 정적 렌더의 `sanitizeHtml` 허용 목록에
20
+ * 이 태그가 없다. 셋 다 `utils/sanitize.ts` 와 태그 이름(`tiptap-upload-skeleton`)으로
21
+ * 정합을 맞춘다 — 이름을 바꾸면 그쪽 규칙도 함께 바꿔야 한다.
22
+ */
23
+ import { Node, type Editor, type JSONContent } from "@tiptap/core";
24
+ /** 노드 이름이자 직렬화 태그 이름. `utils/sanitize.ts` 의 제거 규칙과 짝이다. */
25
+ export declare const UPLOAD_SKELETON_NODE = "tiptap-upload-skeleton";
26
+ /** 실제 노드가 설 때의 대략적 높이 — 교체 순간 문서가 덜 출렁이게 한다. */
27
+ declare const DEFAULT_HEIGHT: {
28
+ readonly image: 220;
29
+ readonly file: 56;
30
+ readonly pdf: 420;
31
+ readonly embed: 420;
32
+ readonly block: 180;
33
+ };
34
+ export type UploadSkeletonKind = keyof typeof DEFAULT_HEIGHT;
35
+ export interface InsertUploadSkeletonOptions {
36
+ kind?: UploadSkeletonKind;
37
+ height?: number;
38
+ /** 삽입 위치. 생략하면 현재 선택 위치. */
39
+ at?: number;
40
+ select?: boolean;
41
+ /** 스켈레톤 뒤에 빈 문단을 하나 심는다 — 커서가 이어서 쓸 자리. 기본 켬. */
42
+ insertParagraph?: boolean;
43
+ }
44
+ interface ReplaceOptions {
45
+ select?: boolean;
46
+ }
47
+ type EditorLike = Pick<Editor, "view">;
48
+ export interface UploadSkeletonHandle {
49
+ id: string;
50
+ exists: () => boolean;
51
+ replaceWith: (content: JSONContent, options?: ReplaceOptions) => boolean;
52
+ remove: () => boolean;
53
+ }
54
+ /**
55
+ * 스켈레톤을 세우고 교체/제거 핸들을 돌려준다. 스키마에 노드가 없으면(확장 미등록)
56
+ * `null` — 호출부는 그때만 다른 진행 표시로 물러난다.
57
+ */
58
+ export declare function insertUploadSkeleton(editor: EditorLike, { kind, height, at, select, insertParagraph }?: InsertUploadSkeletonOptions): UploadSkeletonHandle | null;
59
+ export declare const UploadSkeleton: Node<any, any>;
60
+ export {};
61
+ //# sourceMappingURL=UploadSkeleton.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UploadSkeleton.d.ts","sourceRoot":"","sources":["../../src/extensions/UploadSkeleton.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,EAAE,IAAI,EAAmB,KAAK,MAAM,EAAE,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAIpF,2DAA2D;AAC3D,eAAO,MAAM,oBAAoB,2BAA2B,CAAC;AAE7D,gDAAgD;AAChD,QAAA,MAAM,cAAc;;;;;;CAMV,CAAC;AAKX,MAAM,MAAM,kBAAkB,GAAG,MAAM,OAAO,cAAc,CAAC;AAE7D,MAAM,WAAW,2BAA2B;IAC3C,IAAI,CAAC,EAAE,kBAAkB,CAAC;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4BAA4B;IAC5B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,iDAAiD;IACjD,eAAe,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,UAAU,cAAc;IACvB,MAAM,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,KAAK,UAAU,GAAG,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAEvC,MAAM,WAAW,oBAAoB;IACpC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,OAAO,CAAC;IACtB,WAAW,EAAE,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,cAAc,KAAK,OAAO,CAAC;IACzE,MAAM,EAAE,MAAM,OAAO,CAAC;CACtB;AAiCD;;;GAGG;AACH,wBAAgB,oBAAoB,CACnC,MAAM,EAAE,UAAU,EAClB,EACC,IAAc,EACd,MAA6B,EAC7B,EAAE,EACF,MAAa,EACb,eAAsB,EACtB,GAAE,2BAAgC,GACjC,oBAAoB,GAAG,IAAI,CA+F7B;AAED,eAAO,MAAM,cAAc,gBA2FzB,CAAC"}
@@ -0,0 +1,233 @@
1
+ /**
2
+ * 업로드 자리표시자 — 업로드가 도는 동안 **삽입 지점에** 서는 회색 스켈레톤 블록.
3
+ * (main 정올 `plugin/upload/skeleton` 참조 — Svelte NodeView 를 vanilla 로 재작성.)
4
+ *
5
+ * 전체 화면 오버레이(`업로드 중...`)와 달리 문서 안 제자리에 서므로, 업로드가 도는
6
+ * 동안에도 커서를 옮기고 다른 곳을 편집할 수 있다. 흐름은 세 동작이 전부다:
7
+ *
8
+ * 1. `insertUploadSkeleton(editor, { kind })` → 스켈레톤이 서고 핸들을 돌려받는다
9
+ * 2. 성공: `handle.replaceWith({ type: "image", attrs: { src } })` — 그 자리가 실제 노드로
10
+ * 3. 실패: `handle.remove()` — 흔적 없이 사라진다
11
+ *
12
+ * 핸들은 위치가 아니라 **`uploadId` 로 노드를 다시 찾는다.** 업로드가 도는 몇 초 사이
13
+ * 사용자가 위쪽에 글을 쓰면 위치가 밀리는데, id 로 찾으면 어디로 밀렸든 맞는 자리를
14
+ * 바꾼다. 그 사이 사용자가 스켈레톤을 지웠으면 조용히 `false` 를 돌려준다.
15
+ *
16
+ * ⚠️ **저장 HTML 에 남으면 안 된다.** 스켈레톤은 "업로드 중" 이라는 화면 상태지 내용이
17
+ * 아니다. 세 겹으로 막는다 — ① `TipTapEditor` 가 `onChange` 로 내보내기 전에
18
+ * `stripUploadSkeletonHtml` 로 걷어내고, ② 문서를 열 때 `transformLegacyHtml` 이 같은
19
+ * 태그를 지우며(과거에 새어 나간 것 정리), ③ 정적 렌더의 `sanitizeHtml` 허용 목록에
20
+ * 이 태그가 없다. 셋 다 `utils/sanitize.ts` 와 태그 이름(`tiptap-upload-skeleton`)으로
21
+ * 정합을 맞춘다 — 이름을 바꾸면 그쪽 규칙도 함께 바꿔야 한다.
22
+ */
23
+ import { Node, mergeAttributes } from "@tiptap/core";
24
+ import { NodeSelection } from "@tiptap/pm/state";
25
+ /** 노드 이름이자 직렬화 태그 이름. `utils/sanitize.ts` 의 제거 규칙과 짝이다. */
26
+ export const UPLOAD_SKELETON_NODE = "tiptap-upload-skeleton";
27
+ /** 실제 노드가 설 때의 대략적 높이 — 교체 순간 문서가 덜 출렁이게 한다. */
28
+ const DEFAULT_HEIGHT = {
29
+ image: 220,
30
+ file: 56,
31
+ pdf: 420,
32
+ embed: 420,
33
+ block: 180
34
+ };
35
+ const MIN_HEIGHT = 44;
36
+ const MAX_HEIGHT = 1200;
37
+ function clampHeight(value) {
38
+ return Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.round(value)));
39
+ }
40
+ function findUploadSkeleton(doc, id) {
41
+ let target = null;
42
+ doc.descendants((node, pos) => {
43
+ if (node.type.name !== UPLOAD_SKELETON_NODE)
44
+ return;
45
+ if (node.attrs.uploadId !== id)
46
+ return;
47
+ target = { pos, node };
48
+ return false;
49
+ });
50
+ return target;
51
+ }
52
+ function tryCreateNodeSelection(doc, pos) {
53
+ if (pos < 0 || pos > doc.content.size)
54
+ return null;
55
+ const node = doc.nodeAt(pos);
56
+ if (!node || node.type.spec.selectable === false)
57
+ return null;
58
+ try {
59
+ return NodeSelection.create(doc, pos);
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /**
66
+ * 스켈레톤을 세우고 교체/제거 핸들을 돌려준다. 스키마에 노드가 없으면(확장 미등록)
67
+ * `null` — 호출부는 그때만 다른 진행 표시로 물러난다.
68
+ */
69
+ export function insertUploadSkeleton(editor, { kind = "block", height = DEFAULT_HEIGHT[kind], at, select = true, insertParagraph = true } = {}) {
70
+ const getState = () => editor.view.state;
71
+ const skeletonType = getState().schema.nodes[UPLOAD_SKELETON_NODE];
72
+ if (!skeletonType)
73
+ return null;
74
+ const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
75
+ const node = skeletonType.create({ uploadId, kind, height: clampHeight(height) });
76
+ const paragraph = getState().schema.nodes.paragraph?.create();
77
+ const state = getState();
78
+ const rawPos = Math.max(0, Math.min(at ?? state.selection.from, state.doc.content.size));
79
+ /*
80
+ * ⚠️ 문단 한가운데 그대로 꽂으면 replace 맞춤이 문단을 **쪼개서** 빈 문단 부스러기를
81
+ * 남긴다(`<p></p>` 가 앞에 하나 더 선다 — 제거해도 안 사라지는 흔적). 텍스트블록
82
+ * 안이면 그 블록의 경계로 옮긴다: 빈 블록이면 앞(그 자리를 차지), 아니면 뒤(쓰던
83
+ * 문단 다음).
84
+ */
85
+ let safePos = rawPos;
86
+ const $pos = state.doc.resolve(rawPos);
87
+ if ($pos.depth > 0 && $pos.parent.isTextblock) {
88
+ safePos = $pos.parent.content.size === 0 ? $pos.before() : $pos.after();
89
+ }
90
+ let tr;
91
+ try {
92
+ tr = state.tr.insert(safePos, node);
93
+ }
94
+ catch {
95
+ /* 스키마가 이 자리를 거부하면(중첩 구조 등) 세우지 않는다 — 호출부가 물러난다. */
96
+ return null;
97
+ }
98
+ /*
99
+ * ⚠️ `safePos` 를 그대로 믿으면 안 된다. 커서가 문단 **안**이면 ProseMirror 의 replace
100
+ * 맞춤이 블록을 문단 밖(앞)으로 밀어 넣어 실제 자리가 어긋난다 — 그 상태로 `safePos`
101
+ * 기준으로 문단을 심으면 엉뚱한 문단이 쪼개지고, 선택도 스켈레톤을 못 잡는다.
102
+ * 방금 넣은 노드를 id 로 되찾아 **실제 자리**를 기준으로 삼는다.
103
+ */
104
+ const inserted = findUploadSkeleton(tr.doc, uploadId);
105
+ if (insertParagraph && paragraph && inserted) {
106
+ tr.insert(inserted.pos + inserted.node.nodeSize, paragraph);
107
+ }
108
+ if (select && inserted) {
109
+ const nodeSelection = tryCreateNodeSelection(tr.doc, inserted.pos);
110
+ if (nodeSelection)
111
+ tr.setSelection(nodeSelection);
112
+ }
113
+ editor.view.dispatch(tr);
114
+ return {
115
+ id: uploadId,
116
+ exists: () => Boolean(findUploadSkeleton(getState().doc, uploadId)),
117
+ replaceWith: (content, options = {}) => {
118
+ const state = getState();
119
+ const target = findUploadSkeleton(state.doc, uploadId);
120
+ if (!target)
121
+ return false;
122
+ if (!content?.type)
123
+ return false;
124
+ let nextNode;
125
+ try {
126
+ nextNode = state.schema.nodeFromJSON(content);
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ const tr = state.tr.replaceWith(target.pos, target.pos + target.node.nodeSize, nextNode);
132
+ if (options.select ?? true) {
133
+ const nodeSelection = tryCreateNodeSelection(tr.doc, target.pos);
134
+ if (nodeSelection)
135
+ tr.setSelection(nodeSelection);
136
+ }
137
+ editor.view.dispatch(tr);
138
+ return true;
139
+ },
140
+ remove: () => {
141
+ const state = getState();
142
+ const target = findUploadSkeleton(state.doc, uploadId);
143
+ if (!target)
144
+ return false;
145
+ const removeFrom = target.pos;
146
+ let removeTo = target.pos + target.node.nodeSize;
147
+ /* 함께 심었던 빈 문단도 걷는다 — 실패했는데 빈 줄만 남으면 티가 난다. */
148
+ const nextNode = state.doc.nodeAt(removeTo);
149
+ if (nextNode?.type.name === "paragraph" && nextNode.content.size === 0) {
150
+ removeTo += nextNode.nodeSize;
151
+ }
152
+ editor.view.dispatch(state.tr.deleteRange(removeFrom, removeTo));
153
+ return true;
154
+ }
155
+ };
156
+ }
157
+ export const UploadSkeleton = Node.create({
158
+ name: UPLOAD_SKELETON_NODE,
159
+ group: "block",
160
+ atom: true,
161
+ draggable: false,
162
+ selectable: true,
163
+ addAttributes() {
164
+ return {
165
+ uploadId: {
166
+ default: null,
167
+ parseHTML: (element) => element.getAttribute("data-upload-id"),
168
+ renderHTML: (attributes) => attributes.uploadId ? { "data-upload-id": attributes.uploadId } : {}
169
+ },
170
+ kind: {
171
+ default: "block",
172
+ parseHTML: (element) => element.getAttribute("data-upload-kind") || "block",
173
+ renderHTML: (attributes) => attributes.kind ? { "data-upload-kind": attributes.kind } : {}
174
+ },
175
+ height: {
176
+ default: DEFAULT_HEIGHT.block,
177
+ parseHTML: (element) => {
178
+ const value = Number.parseInt(element.getAttribute("data-upload-height") || "", 10);
179
+ return Number.isFinite(value) ? value : DEFAULT_HEIGHT.block;
180
+ },
181
+ renderHTML: (attributes) => {
182
+ const raw = Number.parseFloat(String(attributes.height ?? DEFAULT_HEIGHT.block));
183
+ const height = Number.isFinite(raw) ? clampHeight(raw) : DEFAULT_HEIGHT.block;
184
+ return { "data-upload-height": String(height) };
185
+ }
186
+ }
187
+ };
188
+ },
189
+ parseHTML() {
190
+ return [{ tag: UPLOAD_SKELETON_NODE }];
191
+ },
192
+ /*
193
+ * ⚠️ `renderHTML` 은 빈 커스텀 태그 하나만 내보낸다 — 내용이 없으니 새어 나가도
194
+ * 화면에 아무것도 못 그리고, `stripUploadSkeletonHtml`(내보내기)·`transformLegacyHtml`
195
+ * (읽기)·`sanitizeHtml`(정적 렌더) 세 군데가 이 태그를 지운다.
196
+ */
197
+ renderHTML({ HTMLAttributes }) {
198
+ return [UPLOAD_SKELETON_NODE, mergeAttributes(HTMLAttributes)];
199
+ },
200
+ addNodeView() {
201
+ return ({ node }) => {
202
+ const dom = document.createElement("div");
203
+ dom.className = "hce-upload-skeleton";
204
+ dom.setAttribute("data-node-view-wrapper", "");
205
+ dom.contentEditable = "false";
206
+ const shine = document.createElement("div");
207
+ shine.className = "hce-upload-skeleton-shine";
208
+ dom.appendChild(shine);
209
+ for (const row of ["top", "mid", "low"]) {
210
+ const bar = document.createElement("div");
211
+ bar.className = `hce-upload-skeleton-bar is-${row}`;
212
+ dom.appendChild(bar);
213
+ }
214
+ const apply = (current) => {
215
+ const rawKind = current.attrs.kind;
216
+ const kind = typeof rawKind === "string" && rawKind.trim().length ? rawKind : "block";
217
+ dom.setAttribute("data-kind", kind);
218
+ const raw = Number.parseFloat(String(current.attrs.height ?? ""));
219
+ dom.style.height = `${clampHeight(Number.isFinite(raw) ? raw : DEFAULT_HEIGHT.block)}px`;
220
+ };
221
+ apply(node);
222
+ return {
223
+ dom,
224
+ update: (updated) => {
225
+ if (updated.type.name !== UPLOAD_SKELETON_NODE)
226
+ return false;
227
+ apply(updated);
228
+ return true;
229
+ }
230
+ };
231
+ };
232
+ }
233
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=UploadSkeleton.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UploadSkeleton.test.d.ts","sourceRoot":"","sources":["../../src/extensions/UploadSkeleton.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { Editor } from "@tiptap/core";
3
+ import StarterKit from "@tiptap/starter-kit";
4
+ import { NodeSelection } from "@tiptap/pm/state";
5
+ import { UploadSkeleton, insertUploadSkeleton, UPLOAD_SKELETON_NODE } from "./UploadSkeleton";
6
+ import { ResizableImage } from "./ResizableImage";
7
+ import { sanitizeHtml, stripUploadSkeletonHtml, transformLegacyHtml } from "../utils/sanitize";
8
+ /*
9
+ * 스켈레톤의 계약은 세 가지다 — 서고(insert), 그 자리가 실제 노드로 바뀌고(replaceWith),
10
+ * 실패하면 흔적 없이 사라진다(remove). 그리고 무슨 일이 있어도 **저장 HTML 에 남지
11
+ * 않는다.** 여기서는 그 네 가지를 전부 문서 상태로 확인한다.
12
+ */
13
+ describe("UploadSkeleton", () => {
14
+ let editor;
15
+ function createEditor(content = "<p></p>") {
16
+ editor = new Editor({
17
+ element: document.createElement("div"),
18
+ extensions: [StarterKit, UploadSkeleton, ResizableImage.configure({ inline: false })],
19
+ content
20
+ });
21
+ return editor;
22
+ }
23
+ afterEach(() => {
24
+ editor?.destroy();
25
+ });
26
+ function findNode(typeName) {
27
+ let found = null;
28
+ editor.state.doc.descendants((node, pos) => {
29
+ if (found)
30
+ return false;
31
+ if (node.type.name === typeName)
32
+ found = { pos, node };
33
+ return found === null;
34
+ });
35
+ return found;
36
+ }
37
+ it("삽입 — 타입별 높이의 스켈레톤이 서고 뒤에 빈 문단이 온다", () => {
38
+ createEditor("<p>본문</p>");
39
+ const handle = insertUploadSkeleton(editor, { kind: "image" });
40
+ expect(handle).not.toBeNull();
41
+ expect(handle.exists()).toBe(true);
42
+ const found = findNode(UPLOAD_SKELETON_NODE);
43
+ expect(found).not.toBeNull();
44
+ expect(found.node.attrs.kind).toBe("image");
45
+ expect(found.node.attrs.height).toBe(220);
46
+ expect(found.node.attrs.uploadId).toBe(handle.id);
47
+ // 커서가 이어서 쓸 빈 문단이 바로 뒤에 있어야 한다
48
+ const after = editor.state.doc.nodeAt(found.pos + found.node.nodeSize);
49
+ expect(after?.type.name).toBe("paragraph");
50
+ // 스켈레톤이 선택되어 있어야 한다 — 업로드 흐름이 여기서 이어진다
51
+ expect(editor.state.selection).toBeInstanceOf(NodeSelection);
52
+ });
53
+ it("높이는 44–1200 으로 죈다", () => {
54
+ createEditor();
55
+ insertUploadSkeleton(editor, { kind: "block", height: 5000 });
56
+ expect(findNode(UPLOAD_SKELETON_NODE).node.attrs.height).toBe(1200);
57
+ editor.commands.clearContent();
58
+ insertUploadSkeleton(editor, { kind: "file", height: 1 });
59
+ expect(findNode(UPLOAD_SKELETON_NODE).node.attrs.height).toBe(44);
60
+ });
61
+ it("replaceWith — 그 자리가 실제 노드로 바뀐다", () => {
62
+ createEditor("<p>본문</p>");
63
+ const handle = insertUploadSkeleton(editor, { kind: "image" });
64
+ expect(handle.replaceWith({ type: "image", attrs: { src: "/a.png" } })).toBe(true);
65
+ expect(findNode(UPLOAD_SKELETON_NODE)).toBeNull();
66
+ expect(handle.exists()).toBe(false);
67
+ const image = findNode("image");
68
+ expect(image).not.toBeNull();
69
+ expect(image.node.attrs.src).toBe("/a.png");
70
+ });
71
+ it("위쪽을 편집해 자리가 밀려도 id 로 찾아 바꾼다", () => {
72
+ createEditor("<p>본문</p>");
73
+ const handle = insertUploadSkeleton(editor, { kind: "image", at: editor.state.doc.content.size });
74
+ // 업로드가 도는 사이 문서 맨 앞에 글이 들어온다 — 위치가 전부 밀린다
75
+ editor.commands.insertContentAt(1, "새로 쓴 글");
76
+ expect(handle.replaceWith({ type: "image", attrs: { src: "/b.png" } })).toBe(true);
77
+ expect(findNode("image").node.attrs.src).toBe("/b.png");
78
+ expect(findNode(UPLOAD_SKELETON_NODE)).toBeNull();
79
+ });
80
+ it("remove — 함께 심은 빈 문단까지 걷어 원래 문서로 돌아간다", () => {
81
+ createEditor("<p>본문</p>");
82
+ const before = editor.getHTML();
83
+ const handle = insertUploadSkeleton(editor, { kind: "pdf" });
84
+ expect(handle.remove()).toBe(true);
85
+ expect(editor.getHTML()).toBe(before);
86
+ });
87
+ it("사용자가 스켈레톤을 지웠으면 조용히 실패한다", () => {
88
+ createEditor("<p>본문</p>");
89
+ const handle = insertUploadSkeleton(editor, { kind: "file" });
90
+ editor.commands.clearContent();
91
+ expect(handle.exists()).toBe(false);
92
+ expect(handle.replaceWith({ type: "image", attrs: { src: "/c.png" } })).toBe(false);
93
+ expect(handle.remove()).toBe(false);
94
+ });
95
+ it("스키마에 노드가 없으면 null — 호출부가 다른 진행 표시로 물러날 신호", () => {
96
+ const bare = new Editor({
97
+ element: document.createElement("div"),
98
+ extensions: [StarterKit],
99
+ content: "<p></p>"
100
+ });
101
+ expect(insertUploadSkeleton(bare)).toBeNull();
102
+ bare.destroy();
103
+ });
104
+ /*
105
+ * 영속 금지 — 스켈레톤이 서 있는 채로 저장이 돌더라도, 내보내기(`strip…`)·읽기
106
+ * (`transformLegacyHtml`)·정적 렌더(`sanitizeHtml`) 어느 길로도 살아남지 못한다.
107
+ */
108
+ it("직렬화엔 나오지만 세 관문이 전부 걷어낸다", () => {
109
+ createEditor("<p>본문</p>");
110
+ insertUploadSkeleton(editor, { kind: "image" });
111
+ const html = editor.getHTML();
112
+ expect(html).toContain("<tiptap-upload-skeleton");
113
+ for (const cleaned of [
114
+ stripUploadSkeletonHtml(html),
115
+ transformLegacyHtml(html),
116
+ sanitizeHtml(html)
117
+ ]) {
118
+ expect(cleaned).not.toContain("tiptap-upload-skeleton");
119
+ expect(cleaned).toContain("본문");
120
+ }
121
+ });
122
+ it("stripUploadSkeletonHtml 은 닫는 태그 유무를 가리지 않고, 없으면 원본 그대로", () => {
123
+ expect(stripUploadSkeletonHtml('<tiptap-upload-skeleton data-upload-id="x"></tiptap-upload-skeleton><p>a</p>')).toBe("<p>a</p>");
124
+ expect(stripUploadSkeletonHtml('<tiptap-upload-skeleton data-upload-id="x"><p>a</p>')).toBe("<p>a</p>");
125
+ const clean = "<p>스켈레톤 없음</p>";
126
+ expect(stripUploadSkeletonHtml(clean)).toBe(clean);
127
+ });
128
+ });
@@ -20,6 +20,14 @@ declare module "@tiptap/core" {
20
20
  };
21
21
  }
22
22
  }
23
+ /**
24
+ * 유튜브 주소면 임베드 주소를, 아니면 null 을 돌려준다.
25
+ *
26
+ * `toEmbedUrl` 의 유튜브 갈래를 떼어 낸 것 — 붙여넣기 자동 임베드(아래
27
+ * `addProseMirrorPlugins`)가 **유튜브 판별기로도** 써야 해서다. "변환 결과가 달라졌는가"
28
+ * 로 어림하면 이미 임베드 꼴인 주소(`youtube.com/embed/ID`)를 놓친다.
29
+ */
30
+ export declare function youTubeEmbedUrl(raw: string): string | null;
23
31
  /**
24
32
  * 붙여넣은 주소를 **iframe 에 넣을 수 있는 주소**로 바꾼다.
25
33
  *
@@ -1 +1 @@
1
- {"version":3,"file":"VideoEmbed.d.ts","sourceRoot":"","sources":["../../src/extensions/VideoEmbed.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;AAGrD,MAAM,WAAW,iBAAiB;IACjC,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC;AAED,OAAO,QAAQ,cAAc,CAAC;IAC7B,UAAU,QAAQ,CAAC,UAAU;QAC5B,UAAU,EAAE;YACX,aAAa,EAAE,CAAC,KAAK,EAAE;gBAAE,GAAG,EAAE,MAAM,CAAC;gBAAC,KAAK,CAAC,EAAE,MAAM,CAAA;aAAE,KAAK,UAAU,CAAC;SACtE,CAAC;KACF;CACD;AAWD;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAqD9C;AAmBD,eAAO,MAAM,UAAU,8BA0JrB,CAAC"}
1
+ {"version":3,"file":"VideoEmbed.d.ts","sourceRoot":"","sources":["../../src/extensions/VideoEmbed.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;AAWrD,MAAM,WAAW,iBAAiB;IACjC,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC;AAED,OAAO,QAAQ,cAAc,CAAC;IAC7B,UAAU,QAAQ,CAAC,UAAU;QAC5B,UAAU,EAAE;YACX,aAAa,EAAE,CAAC,KAAK,EAAE;gBAAE,GAAG,EAAE,MAAM,CAAC;gBAAC,KAAK,CAAC,EAAE,MAAM,CAAA;aAAE,KAAK,UAAU,CAAC;SACtE,CAAC;KACF;CACD;AAyBD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAuC1D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CA2B9C;AAmBD,eAAO,MAAM,UAAU,8BAmSrB,CAAC"}