@teriusu/rich-editor 0.10.8 → 0.11.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 (147) hide show
  1. package/dist/components/BubbleToolbar.svelte +182 -54
  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
@@ -0,0 +1,291 @@
1
+ <script lang="ts">
2
+ import type { Editor } from "@tiptap/core";
3
+ import {
4
+ findEmojiQuery,
5
+ searchEmojis,
6
+ type EmojiSuggestion,
7
+ } from "../utils/emoji-suggestion";
8
+
9
+ /*
10
+ * 이모지 `:` 자동완성의 **화면 절반** — 판정·검색은 `utils/emoji-suggestion.ts`.
11
+ *
12
+ * 슬래시 메뉴와 달리 상태(열림·위치·검색어)를 이 컴포넌트가 전부 들고 있다.
13
+ * `TipTapEditor` 는 마운트만 한다 — 에디터 update/selectionUpdate 를 여기서 직접
14
+ * 구독하므로 에디터 쪽에 끼워 넣을 배선이 없다.
15
+ *
16
+ * 키보드는 슬래시 메뉴(`SlashCommandMenu`)와 같은 방식: 문서 캡처 단계에서 가로챈다.
17
+ * `preventDefault` 만으로 충분하다 — prosemirror-view 는 `defaultPrevented` 인 keydown
18
+ * 을 버린다(설치본 `input.ts` 확인). Tab 이동은 main(emoji.ts)에서 가져온 것이다.
19
+ */
20
+ /*
21
+ * `onOpenChange` 는 실표시(`visible`) 기준 알림이다 — `TipTapEditor` 가 빈 줄 플로팅
22
+ * 헬퍼를 이 팝업과 동시에 띄우지 않으려고 받는다. 열려 있어도 후보가 없어 안 그리면
23
+ * (`visible` 주석) 닫힌 것으로 알린다.
24
+ */
25
+ let {
26
+ editor,
27
+ onOpenChange,
28
+ }: { editor: Editor; onOpenChange?: (open: boolean) => void } = $props();
29
+
30
+ let open = $state(false);
31
+ let pos = $state({ top: 0, left: 0 });
32
+ let query = $state("");
33
+ let range: { from: number; to: number } | null = null;
34
+ let selectedIndex = $state(0);
35
+ let menuEl: HTMLDivElement | undefined = $state();
36
+ let mouseMovedSinceKeyboard = $state(true);
37
+
38
+ const items = $derived(searchEmojis(query));
39
+ /*
40
+ * 열려 있어도 후보가 없으면 **없는 것처럼** 군다 — 빈 상자를 띄우지 않고, 키보드도
41
+ * 가로채지 않는다(`:x` 뒤에 일반 문장을 이어 칠 때 Enter 를 뺏으면 안 된다).
42
+ */
43
+ const visible = $derived(open && items.length > 0);
44
+
45
+ $effect(() => {
46
+ onOpenChange?.(visible);
47
+ /* 열린 채로 내려가는 경우(editable 전환 등)에도 닫힘을 알린다. */
48
+ return () => {
49
+ if (visible) onOpenChange?.(false);
50
+ };
51
+ });
52
+
53
+ const MENU_HEIGHT = 320;
54
+
55
+ /* `updateSlashMenuPosition`(TipTapEditor)과 같은 계산 — 고정 툴바·하단 바를 피해 뒤집는다. */
56
+ function updatePosition() {
57
+ if (!range) return;
58
+ const coords = editor.view.coordsAtPos(range.to);
59
+
60
+ const headerHeight = parseInt(
61
+ getComputedStyle(document.documentElement).getPropertyValue(
62
+ "--header-height",
63
+ ) || "74",
64
+ );
65
+ const toolbarEl = editor.view.dom
66
+ .closest(".hce-editor-wrapper")
67
+ ?.querySelector(".sticky");
68
+ const toolbarBottom = toolbarEl
69
+ ? toolbarEl.getBoundingClientRect().bottom
70
+ : headerHeight;
71
+ const topSafe = Math.max(toolbarBottom, headerHeight) + 4;
72
+
73
+ const bottomBar = document.querySelector(".sticky.bottom-0");
74
+ const bottomBarHeight = bottomBar
75
+ ? bottomBar.getBoundingClientRect().height
76
+ : 0;
77
+ const bottomSafe = window.innerHeight - bottomBarHeight - 4;
78
+
79
+ const spaceBelow = bottomSafe - coords.bottom;
80
+ const spaceAbove = coords.top - topSafe;
81
+ const goUp = spaceBelow < MENU_HEIGHT && spaceAbove > spaceBelow;
82
+
83
+ let top: number;
84
+ if (goUp) {
85
+ top = Math.max(topSafe, coords.top - MENU_HEIGHT);
86
+ } else {
87
+ top = Math.min(coords.bottom + 4, bottomSafe - MENU_HEIGHT);
88
+ }
89
+
90
+ pos = { top, left: coords.left };
91
+ }
92
+
93
+ function close() {
94
+ open = false;
95
+ range = null;
96
+ query = "";
97
+ }
98
+
99
+ /* 문서가 바뀔 때(타이핑) — 열고 닫기 전부 판정한다. */
100
+ function handleUpdate() {
101
+ const match = findEmojiQuery(editor.state);
102
+ if (!match) {
103
+ if (open) close();
104
+ return;
105
+ }
106
+ range = { from: match.from, to: match.to };
107
+ if (match.query !== query) {
108
+ query = match.query;
109
+ selectedIndex = 0;
110
+ }
111
+ open = true;
112
+ updatePosition();
113
+ }
114
+
115
+ /*
116
+ * 선택만 움직일 때(클릭·화살표 이동)는 **닫기만** 판정한다 — 슬래시 메뉴의
117
+ * `handleSelectionUpdate` 와 같은 비대칭. 옛 `:word` 텍스트를 클릭했다고 팝업이
118
+ * 열리면 안 된다.
119
+ */
120
+ function handleSelectionUpdate() {
121
+ if (!open) return;
122
+ if (!findEmojiQuery(editor.state)) close();
123
+ }
124
+
125
+ $effect(() => {
126
+ editor.on("update", handleUpdate);
127
+ editor.on("selectionUpdate", handleSelectionUpdate);
128
+ return () => {
129
+ editor.off("update", handleUpdate);
130
+ editor.off("selectionUpdate", handleSelectionUpdate);
131
+ };
132
+ });
133
+
134
+ function select(item: EmojiSuggestion) {
135
+ if (!range) return;
136
+ const { from, to } = range;
137
+ close();
138
+ // main 과 같은 삽입 꼴 — `:query` 를 지우고 이모지 + 공백.
139
+ editor
140
+ .chain()
141
+ .focus()
142
+ .deleteRange({ from, to })
143
+ .insertContent(`${item.emoji} `)
144
+ .run();
145
+ }
146
+
147
+ $effect(() => {
148
+ if (!visible) return;
149
+ const onScroll = () => updatePosition();
150
+ window.addEventListener("scroll", onScroll, true);
151
+ return () => window.removeEventListener("scroll", onScroll, true);
152
+ });
153
+
154
+ $effect(() => {
155
+ if (!visible) return;
156
+ function handleMouseDown(e: MouseEvent) {
157
+ if (menuEl && !menuEl.contains(e.target as Node)) close();
158
+ }
159
+ document.addEventListener("mousedown", handleMouseDown, true);
160
+ return () =>
161
+ document.removeEventListener("mousedown", handleMouseDown, true);
162
+ });
163
+
164
+ $effect(() => {
165
+ if (!visible) return;
166
+ function handleKeyDown(e: KeyboardEvent) {
167
+ if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) {
168
+ e.preventDefault();
169
+ mouseMovedSinceKeyboard = false;
170
+ selectedIndex = (selectedIndex + 1) % items.length;
171
+ } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) {
172
+ e.preventDefault();
173
+ mouseMovedSinceKeyboard = false;
174
+ selectedIndex =
175
+ selectedIndex <= 0 ? items.length - 1 : selectedIndex - 1;
176
+ } else if (e.key === "Enter") {
177
+ e.preventDefault();
178
+ const item = items[selectedIndex];
179
+ if (item) select(item);
180
+ } else if (e.key === "Escape") {
181
+ // main 처럼 친 텍스트는 남기고 팝업만 접는다(다음 글자를 치면 다시 뜬다).
182
+ e.preventDefault();
183
+ open = false;
184
+ }
185
+ }
186
+ document.addEventListener("keydown", handleKeyDown, true);
187
+ return () => document.removeEventListener("keydown", handleKeyDown, true);
188
+ });
189
+
190
+ $effect(() => {
191
+ // 선택 항목을 보이는 자리로
192
+ if (menuEl) {
193
+ const el = menuEl.querySelector(`[data-index="${selectedIndex}"]`);
194
+ el?.scrollIntoView({ block: "nearest" });
195
+ }
196
+ });
197
+ </script>
198
+
199
+ {#if visible}
200
+ <div class="fixed z-50" style="top: {pos.top}px; left: {pos.left}px">
201
+ <div
202
+ bind:this={menuEl}
203
+ class="emoji-menu z-50 bg-popover border border-border rounded-xl shadow-xl overflow-y-auto py-1.5"
204
+ >
205
+ {#each items as item, i (item.keyword)}
206
+ <button
207
+ type="button"
208
+ data-index={i}
209
+ class="emoji-item {i === selectedIndex ? 'is-selected' : ''}"
210
+ onmousemove={() => {
211
+ if (!mouseMovedSinceKeyboard) {
212
+ mouseMovedSinceKeyboard = true;
213
+ return;
214
+ }
215
+ selectedIndex = i;
216
+ }}
217
+ onclick={() => select(item)}
218
+ >
219
+ <span class="emoji-glyph">{item.emoji}</span>
220
+ <span class="emoji-keyword">{item.keyword}</span>
221
+ </button>
222
+ {/each}
223
+ </div>
224
+ </div>
225
+ {/if}
226
+
227
+ <style>
228
+ .emoji-menu {
229
+ width: 224px;
230
+ max-height: 320px;
231
+ }
232
+
233
+ /* `SlashCommandMenu` 의 .slash-item 과 같은 결 — 두 팝업이 같은 물건으로 보여야 한다. */
234
+ .emoji-item {
235
+ position: relative;
236
+ display: flex;
237
+ align-items: center;
238
+ gap: 8px;
239
+ width: 100%;
240
+ padding: 4px 10px;
241
+ text-align: left;
242
+ font-size: 12px;
243
+ font-weight: 500;
244
+ color: var(--foreground);
245
+ background: transparent;
246
+ border: 0;
247
+ cursor: pointer;
248
+ transition: background-color 0.12s;
249
+ }
250
+
251
+ .emoji-item:hover,
252
+ .emoji-item.is-selected {
253
+ background: color-mix(in srgb, var(--primary) 8%, transparent);
254
+ color: var(--primary);
255
+ }
256
+
257
+ .emoji-item.is-selected::before {
258
+ content: '';
259
+ position: absolute;
260
+ left: 0;
261
+ top: 3px;
262
+ bottom: 3px;
263
+ width: 3px;
264
+ border-radius: 0 2px 2px 0;
265
+ background: var(--primary);
266
+ }
267
+
268
+ .emoji-glyph {
269
+ flex-shrink: 0;
270
+ width: 20px;
271
+ font-size: 15px;
272
+ line-height: 1;
273
+ text-align: center;
274
+ }
275
+
276
+ .emoji-keyword {
277
+ flex: 1;
278
+ min-width: 0;
279
+ overflow: hidden;
280
+ text-overflow: ellipsis;
281
+ white-space: nowrap;
282
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
283
+ font-size: 11px;
284
+ color: var(--muted-foreground);
285
+ }
286
+
287
+ .emoji-item.is-selected .emoji-keyword,
288
+ .emoji-item:hover .emoji-keyword {
289
+ color: var(--primary);
290
+ }
291
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ type $$ComponentProps = {
3
+ editor: Editor;
4
+ onOpenChange?: (open: boolean) => void;
5
+ };
6
+ declare const EmojiSuggestionMenu: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type EmojiSuggestionMenu = ReturnType<typeof EmojiSuggestionMenu>;
8
+ export default EmojiSuggestionMenu;
9
+ //# sourceMappingURL=EmojiSuggestionMenu.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EmojiSuggestionMenu.svelte.d.ts","sourceRoot":"","sources":["../../src/components/EmojiSuggestionMenu.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAO1C,KAAK,gBAAgB,GAAI;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;CAAE,CAAC;AA0NrF,QAAA,MAAM,mBAAmB,sDAAwC,CAAC;AAClE,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAClE,eAAe,mBAAmB,CAAC"}
@@ -41,6 +41,7 @@
41
41
  import ToggleHeadingIcon from "./icons/ToggleHeadingIcon.svelte";
42
42
  import InputModal from "./InputModal.svelte";
43
43
  import type { ToolbarFeature, PromptHandler } from "../types";
44
+ import { defaultTranslator, type EditorTranslator } from "../i18n";
44
45
 
45
46
  let {
46
47
  editor,
@@ -52,6 +53,7 @@
52
53
  onPromptMbus,
53
54
  onPromptVideo,
54
55
  toolbarEnd,
56
+ t = defaultTranslator,
55
57
  }: {
56
58
  editor: Editor;
57
59
  features: Set<ToolbarFeature>;
@@ -76,6 +78,8 @@
76
78
  * 여기에 넣으면 툴바의 flex 행에 그대로 얹혀 정렬이 저절로 맞는다.
77
79
  */
78
80
  toolbarEnd?: Snippet;
81
+ /** 에디터 UI 번역 함수. 미주입 시 ko. */
82
+ t?: EditorTranslator;
79
83
  } = $props();
80
84
 
81
85
  /*
@@ -171,21 +175,21 @@
171
175
  }
172
176
 
173
177
  const currentBlockLabel = $derived.by(() => {
174
- if (isActive("heading", { level: 1 })) return "제목 1";
175
- if (isActive("heading", { level: 2 })) return "제목 2";
176
- if (isActive("heading", { level: 3 })) return "제목 3";
177
- if (isActive("bulletList")) return "글머리 목록";
178
- if (isActive("orderedList")) return "번호 목록";
179
- if (isActive("taskList")) return "체크리스트";
180
- if (isActive("blockquote")) return "인용문";
178
+ if (isActive("heading", { level: 1 })) return t("heading1");
179
+ if (isActive("heading", { level: 2 })) return t("heading2");
180
+ if (isActive("heading", { level: 3 })) return t("heading3");
181
+ if (isActive("bulletList")) return t("bulletList");
182
+ if (isActive("orderedList")) return t("orderedList");
183
+ if (isActive("taskList")) return t("taskList");
184
+ if (isActive("blockquote")) return t("blockquote");
181
185
  if (isActive("details")) {
182
186
  // 토글 제목이면 단계까지 보여 준다 — 그냥 "토글" 이면 무엇을 고른 건지 안 보인다.
183
187
  for (const lv of [1, 2, 3]) {
184
- if (isActive("detailsSummary", { level: lv })) return `토글 제목 ${lv}`;
188
+ if (isActive("detailsSummary", { level: lv })) return t("toggleHeading", { level: lv });
185
189
  }
186
- return "토글";
190
+ return t("toggle");
187
191
  }
188
- return "본문";
192
+ return t("paragraph");
189
193
  });
190
194
 
191
195
  function runBlock(fn: () => void) {
@@ -238,7 +242,7 @@
238
242
  type="button"
239
243
  onclick={() => editor.chain().focus().undo().run()}
240
244
  disabled={!canDo((c) => c.undo())}
241
- aria-label="실행 취소"
245
+ aria-label={t('undo')}
242
246
  class={cn(
243
247
  "p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground",
244
248
  !canDo((c) => c.undo()) && "opacity-30 pointer-events-none",
@@ -252,7 +256,7 @@
252
256
  type="button"
253
257
  onclick={() => editor.chain().focus().redo().run()}
254
258
  disabled={!canDo((c) => c.redo())}
255
- aria-label="다시 실행"
259
+ aria-label={t('redo')}
256
260
  class={cn(
257
261
  "p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground",
258
262
  !canDo((c) => c.redo()) && "opacity-30 pointer-events-none",
@@ -271,7 +275,7 @@
271
275
  <button
272
276
  type="button"
273
277
  onclick={() => (blockMenuOpen = !blockMenuOpen)}
274
- aria-label="블록 타입"
278
+ aria-label={t('blockType')}
275
279
  class="flex items-center gap-1 px-2 py-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground min-w-[96px]"
276
280
  >
277
281
  <span class="text-sm">{currentBlockLabel}</span>
@@ -301,7 +305,7 @@
301
305
  onclick={() =>
302
306
  runBlock(() => editor.chain().focus().setParagraph().run())}
303
307
  >
304
- <Pilcrow size={14} /> 본문
308
+ <Pilcrow size={14} /> {t('paragraph')}
305
309
  </button>
306
310
  {#if has('h1')}
307
311
  <button
@@ -315,7 +319,7 @@
315
319
  editor.chain().focus().toggleHeading({ level: 1 }).run(),
316
320
  )}
317
321
  >
318
- <Heading1 size={14} /> 제목 1
322
+ <Heading1 size={14} /> {t('heading1')}
319
323
  <span class="hce-menu-shortcut"># </span>
320
324
  </button>
321
325
  {/if}
@@ -331,7 +335,7 @@
331
335
  editor.chain().focus().toggleHeading({ level: 2 }).run(),
332
336
  )}
333
337
  >
334
- <Heading2 size={14} /> 제목 2
338
+ <Heading2 size={14} /> {t('heading2')}
335
339
  <span class="hce-menu-shortcut">## </span>
336
340
  </button>
337
341
  {/if}
@@ -347,7 +351,7 @@
347
351
  editor.chain().focus().toggleHeading({ level: 3 }).run(),
348
352
  )}
349
353
  >
350
- <Heading3 size={14} /> 제목 3
354
+ <Heading3 size={14} /> {t('heading3')}
351
355
  <span class="hce-menu-shortcut">### </span>
352
356
  </button>
353
357
  {/if}
@@ -364,7 +368,7 @@
364
368
  onclick={() =>
365
369
  runBlock(() => editor.chain().focus().toggleBulletList().run())}
366
370
  >
367
- <List size={14} /> 글머리 목록
371
+ <List size={14} /> {t('bulletList')}
368
372
  <span class="hce-menu-shortcut">- </span>
369
373
  </button>
370
374
  {/if}
@@ -378,7 +382,7 @@
378
382
  onclick={() =>
379
383
  runBlock(() => editor.chain().focus().toggleOrderedList().run())}
380
384
  >
381
- <ListOrdered size={14} /> 번호 목록
385
+ <ListOrdered size={14} /> {t('orderedList')}
382
386
  <span class="hce-menu-shortcut">1. </span>
383
387
  </button>
384
388
  {/if}
@@ -392,7 +396,7 @@
392
396
  onclick={() =>
393
397
  runBlock(() => editor.chain().focus().toggleTaskList().run())}
394
398
  >
395
- <CheckSquare size={14} /> 체크리스트
399
+ <CheckSquare size={14} /> {t('taskList')}
396
400
  <span class="hce-menu-shortcut">[] </span>
397
401
  </button>
398
402
  {/if}
@@ -409,7 +413,7 @@
409
413
  onclick={() =>
410
414
  runBlock(() => editor.chain().focus().toggleBlockquote().run())}
411
415
  >
412
- <Quote size={14} /> 인용문
416
+ <Quote size={14} /> {t('blockquote')}
413
417
  <span class="hce-menu-shortcut">" </span>
414
418
  </button>
415
419
  {/if}
@@ -423,7 +427,7 @@
423
427
  onclick={() =>
424
428
  runBlock(() => editor.chain().focus().setDetails().run())}
425
429
  >
426
- <ChevronRight size={14} /> 토글
430
+ <ChevronRight size={14} /> {t('toggle')}
427
431
  <span class="hce-menu-shortcut">> </span>
428
432
  </button>
429
433
  <!--
@@ -450,7 +454,7 @@
450
454
  .run(),
451
455
  )}
452
456
  >
453
- <ToggleHeadingIcon size={14} level={level as 1 | 2 | 3} /> 토글 제목 {level}
457
+ <ToggleHeadingIcon size={14} level={level as 1 | 2 | 3} /> {t('toggleHeading', { level })}
454
458
  <span class="hce-menu-shortcut">{'#'.repeat(level)} &gt; </span>
455
459
  </button>
456
460
  {/each}
@@ -475,7 +479,7 @@
475
479
  <button
476
480
  type="button"
477
481
  onclick={() => editor.chain().focus().toggleBold().run()}
478
- aria-label="굵게"
482
+ aria-label={t('bold')}
479
483
  class={cn(
480
484
  "p-1.5 rounded-md transition-colors",
481
485
  isActive("bold")
@@ -490,7 +494,7 @@
490
494
  <button
491
495
  type="button"
492
496
  onclick={() => editor.chain().focus().toggleItalic().run()}
493
- aria-label="기울임"
497
+ aria-label={t('italic')}
494
498
  class={cn(
495
499
  "p-1.5 rounded-md transition-colors",
496
500
  isActive("italic")
@@ -505,7 +509,7 @@
505
509
  <button
506
510
  type="button"
507
511
  onclick={() => editor.chain().focus().toggleUnderline().run()}
508
- aria-label="밑줄"
512
+ aria-label={t('underline')}
509
513
  class={cn(
510
514
  "p-1.5 rounded-md transition-colors",
511
515
  isActive("underline")
@@ -520,7 +524,7 @@
520
524
  <button
521
525
  type="button"
522
526
  onclick={() => editor.chain().focus().toggleStrike().run()}
523
- aria-label="취소선"
527
+ aria-label={t('strike')}
524
528
  class={cn(
525
529
  "p-1.5 rounded-md transition-colors",
526
530
  isActive("strike")
@@ -552,7 +556,7 @@
552
556
  <button
553
557
  type="button"
554
558
  onclick={() => editor.chain().focus().setTextAlign('left').run()}
555
- aria-label="왼쪽 정렬"
559
+ aria-label={t('alignLeft')}
556
560
  class={cn(
557
561
  "p-1.5 rounded-md transition-colors",
558
562
  isActive({ textAlign: 'left' })
@@ -567,7 +571,7 @@
567
571
  <button
568
572
  type="button"
569
573
  onclick={() => editor.chain().focus().setTextAlign('center').run()}
570
- aria-label="가운데 정렬"
574
+ aria-label={t('alignCenter')}
571
575
  class={cn(
572
576
  "p-1.5 rounded-md transition-colors",
573
577
  isActive({ textAlign: 'center' })
@@ -582,7 +586,7 @@
582
586
  <button
583
587
  type="button"
584
588
  onclick={() => editor.chain().focus().setTextAlign('right').run()}
585
- aria-label="오른쪽 정렬"
589
+ aria-label={t('alignRight')}
586
590
  class={cn(
587
591
  "p-1.5 rounded-md transition-colors",
588
592
  isActive({ textAlign: 'right' })
@@ -603,11 +607,11 @@
603
607
  <button
604
608
  type="button"
605
609
  onclick={() => (insertMenuOpen = !insertMenuOpen)}
606
- aria-label="삽입"
610
+ aria-label={t('insert')}
607
611
  class="flex items-center gap-1 px-2 py-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
608
612
  >
609
613
  <Plus size={iconSize} />
610
- <span class="text-sm">삽입</span>
614
+ <span class="text-sm">{t('insert')}</span>
611
615
  <ChevronDown size={12} />
612
616
  </button>
613
617
  {#if insertMenuOpen}
@@ -622,7 +626,7 @@
622
626
  onclick={() =>
623
627
  runInsert(() => editor.chain().focus().setCodeBlock().run())}
624
628
  >
625
- <Code2 size={14} /> 코드 블록
629
+ <Code2 size={14} /> {t('codeBlock')}
626
630
  <span class="hce-menu-shortcut">```</span>
627
631
  </button>
628
632
  {/if}
@@ -633,7 +637,7 @@
633
637
  onclick={() =>
634
638
  runInsert(() => editor.chain().focus().promptMathDisplay().run())}
635
639
  >
636
- <Sigma size={14} /> 수식 블록
640
+ <Sigma size={14} /> {t('mathBlock')}
637
641
  <span class="hce-menu-shortcut">$$</span>
638
642
  </button>
639
643
  <!--
@@ -648,7 +652,7 @@
648
652
  onclick={() =>
649
653
  runInsert(() => editor.chain().focus().promptMathInline().run())}
650
654
  >
651
- <Sigma size={14} /> 인라인 수식
655
+ <Sigma size={14} /> {t('mathInline')}
652
656
  <span class="hce-menu-shortcut">$ $</span>
653
657
  </button>
654
658
  {/if}
@@ -667,7 +671,7 @@
667
671
  class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
668
672
  onclick={() => runInsert(onFileClick!)}
669
673
  >
670
- <Paperclip size={14} /> 파일
674
+ <Paperclip size={14} /> {t('file')}
671
675
  </button>
672
676
  {/if}
673
677
  {#if has('table')}
@@ -679,7 +683,7 @@
679
683
  insertTableSized(editor, { rows: 3, cols: 3, withHeaderRow: true }),
680
684
  )}
681
685
  >
682
- <TableIcon size={14} /> 표 (3x3)
686
+ <TableIcon size={14} /> {t('table3x3')}
683
687
  </button>
684
688
  {/if}
685
689
 
@@ -695,7 +699,7 @@
695
699
  editor.chain().focus().setHorizontalRule().run(),
696
700
  )}
697
701
  >
698
- <Minus size={14} /> 구분선
702
+ <Minus size={14} /> {t('divider')}
699
703
  <span class="hce-menu-shortcut">---</span>
700
704
  </button>
701
705
  {/if}
@@ -706,7 +710,7 @@
706
710
  onclick={() =>
707
711
  runInsert(() => editor.chain().focus().setColumns(2).run())}
708
712
  >
709
- <Columns2 size={14} /> 2단 컬럼
713
+ <Columns2 size={14} /> {t('columns2')}
710
714
  </button>
711
715
  {/if}
712
716
  {#if has('columns-3')}
@@ -716,7 +720,7 @@
716
720
  onclick={() =>
717
721
  runInsert(() => editor.chain().focus().setColumns(3).run())}
718
722
  >
719
- <Columns3 size={14} /> 3단 컬럼
723
+ <Columns3 size={14} /> {t('columns3')}
720
724
  </button>
721
725
  {/if}
722
726
  {#if has('tabs')}
@@ -726,7 +730,7 @@
726
730
  onclick={() =>
727
731
  runInsert(() => editor.chain().focus().setTabs(3).run())}
728
732
  >
729
- <PanelTop size={14} /> 탭
733
+ <PanelTop size={14} /> {t('tabs')}
730
734
  </button>
731
735
  {/if}
732
736
  {#if has('card')}
@@ -735,7 +739,7 @@
735
739
  class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
736
740
  onclick={() => runInsert(() => editor.chain().focus().setCard().run())}
737
741
  >
738
- <SquareDashed size={14} /> 카드
742
+ <SquareDashed size={14} /> {t('card')}
739
743
  </button>
740
744
  {/if}
741
745
 
@@ -748,7 +752,7 @@
748
752
  class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
749
753
  onclick={addImage}
750
754
  >
751
- <ImageIcon size={14} /> 이미지
755
+ <ImageIcon size={14} /> {t('image')}
752
756
  </button>
753
757
  {/if}
754
758
  {#if has('video')}
@@ -761,7 +765,7 @@
761
765
  class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
762
766
  onclick={addVideo}
763
767
  >
764
- <Youtube size={14} /> 영상
768
+ <Youtube size={14} /> {t('video')}
765
769
  </button>
766
770
  {/if}
767
771
  {#if has('mbus')}
@@ -770,7 +774,7 @@
770
774
  class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
771
775
  onclick={addMbus}
772
776
  >
773
- <Tv size={14} /> 미디버스 영상
777
+ <Tv size={14} /> {t('mbusVideo')}
774
778
  </button>
775
779
  {/if}
776
780
  </div>
@@ -783,24 +787,26 @@
783
787
  <!-- (이미지 모달은 없다 — 에디터가 업로드/링크 탭 모달을 띄운다. `onImageClick` 참고.) -->
784
788
  {#if modalState?.type === "video"}
785
789
  <InputModal
786
- title="영상 URL"
790
+ title={t('videoUrl')}
787
791
  placeholder="https://www.youtube.com/watch?v=..."
788
792
  onConfirm={(url) => {
789
793
  editor.chain().focus().setVideoEmbed({ src: url }).run();
790
794
  modalState = null;
791
795
  }}
792
796
  onCancel={() => (modalState = null)}
797
+ {t}
793
798
  />
794
799
  {/if}
795
800
  {#if modalState?.type === "mbus"}
796
801
  <InputModal
797
- title="미디버스 영상 URL"
802
+ title={t('mbusVideoUrl')}
798
803
  placeholder="https://play.mbus.tv/v1/hls/..."
799
804
  onConfirm={(url) => {
800
805
  editor.chain().focus().setMbusVideo({ src: url }).run();
801
806
  modalState = null;
802
807
  }}
803
808
  onCancel={() => (modalState = null)}
809
+ {t}
804
810
  />
805
811
  {/if}
806
812