@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
@@ -28,15 +28,19 @@
28
28
  } from "lucide-svelte";
29
29
  import { cn } from "../utils/cn";
30
30
  import type { ToolbarFeature, PromptHandler } from "../types";
31
+ import { defaultTranslator, type EditorMessageKey, type EditorTranslator } from "../i18n";
31
32
 
32
33
  let {
33
34
  editor,
34
35
  features,
35
36
  onPromptLink,
37
+ t = defaultTranslator,
36
38
  }: {
37
39
  editor: Editor;
38
40
  features: Set<ToolbarFeature>;
39
41
  onPromptLink?: PromptHandler;
42
+ /** 에디터 UI 번역 함수. 미주입 시 ko. */
43
+ t?: EditorTranslator;
40
44
  } = $props();
41
45
 
42
46
  /*
@@ -65,6 +69,7 @@
65
69
 
66
70
  const has = (f: ToolbarFeature) => features.has(f);
67
71
 
72
+ let mobile = $state(false);
68
73
  let showBlockMenu = $state(false);
69
74
  let showColors = $state(false);
70
75
  let menuEl: HTMLDivElement | undefined = $state();
@@ -72,27 +77,27 @@
72
77
  let colorMenuEl: HTMLDivElement | undefined = $state();
73
78
  const iconSize = 14;
74
79
 
75
- const TEXT_COLORS = [
76
- { label: "기본", value: "" },
77
- { label: "검정", value: "#000000" },
78
- { label: "회색", value: "#6b7280" },
79
- { label: "빨강", value: "#dc2626" },
80
- { label: "주황", value: "#ea580c" },
81
- { label: "노랑", value: "#ca8a04" },
82
- { label: "초록", value: "#16a34a" },
83
- { label: "파랑", value: "#2563eb" },
84
- { label: "보라", value: "#7c3aed" }
80
+ const TEXT_COLORS: { labelKey: EditorMessageKey; value: string }[] = [
81
+ { labelKey: "colorDefault", value: "" },
82
+ { labelKey: "colorBlack", value: "#000000" },
83
+ { labelKey: "colorGray", value: "#6b7280" },
84
+ { labelKey: "colorRed", value: "#dc2626" },
85
+ { labelKey: "colorOrange", value: "#ea580c" },
86
+ { labelKey: "colorYellow", value: "#ca8a04" },
87
+ { labelKey: "colorGreen", value: "#16a34a" },
88
+ { labelKey: "colorBlue", value: "#2563eb" },
89
+ { labelKey: "colorPurple", value: "#7c3aed" }
85
90
  ];
86
91
 
87
92
  function getCurrentBlockLabel(): string {
88
- if (isActive("heading", { level: 1 })) return "제목 1";
89
- if (isActive("heading", { level: 2 })) return "제목 2";
90
- if (isActive("heading", { level: 3 })) return "제목 3";
91
- if (isActive("bulletList")) return "글머리 목록";
92
- if (isActive("orderedList")) return "번호 목록";
93
- if (isActive("taskList")) return "체크리스트";
94
- if (isActive("blockquote")) return "인용문";
95
- return "본문";
93
+ if (isActive("heading", { level: 1 })) return t("heading1");
94
+ if (isActive("heading", { level: 2 })) return t("heading2");
95
+ if (isActive("heading", { level: 3 })) return t("heading3");
96
+ if (isActive("bulletList")) return t("bulletList");
97
+ if (isActive("orderedList")) return t("orderedList");
98
+ if (isActive("taskList")) return t("taskList");
99
+ if (isActive("blockquote")) return t("blockquote");
100
+ return t("paragraph");
96
101
  }
97
102
 
98
103
  function isParagraphActive(): boolean {
@@ -106,11 +111,18 @@
106
111
  }
107
112
 
108
113
  async function addLink() {
114
+ const selection = editor.state.selection.getBookmark();
115
+ const documentBeforePrompt = editor.state.doc;
109
116
  const previousUrl = editor.getAttributes("link").href || "";
110
117
  const url = onPromptLink
111
118
  ? await onPromptLink(previousUrl)
112
- : window.prompt("링크 URL을 입력하세요", previousUrl);
113
- if (url === null) return;
119
+ : window.prompt(t("promptLinkUrl"), previousUrl);
120
+ if (url === null || editor.isDestroyed || editor.state.doc !== documentBeforePrompt) return;
121
+ try {
122
+ editor.view.dispatch(editor.state.tr.setSelection(selection.resolve(editor.state.doc)));
123
+ } catch {
124
+ return;
125
+ }
114
126
  if (url === "") {
115
127
  editor.chain().focus().extendMarkRange("link").unsetLink().run();
116
128
  } else {
@@ -123,8 +135,13 @@
123
135
  }
124
136
  }
125
137
 
126
- function handleDocClick(e: MouseEvent) {
138
+ function handleDocClick(e: Event) {
127
139
  const target = e.target as Node;
140
+ // The upstream plugin treats any focus inside the menu's parent as internal.
141
+ // A body portal needs an explicit boundary so another input/editor dismisses it.
142
+ if (mobile && menuEl && !menuEl.contains(target) && !editor.view.dom.contains(target)) {
143
+ editor.view.dispatch(editor.state.tr.setMeta(bubbleToolbarKey, 'hide'));
144
+ }
128
145
  if (showBlockMenu && blockMenuEl && !blockMenuEl.contains(target)) {
129
146
  showBlockMenu = false;
130
147
  }
@@ -143,12 +160,74 @@
143
160
  onMount(() => {
144
161
  if (!menuEl) return;
145
162
 
163
+ const element = menuEl;
164
+ const media = window.matchMedia('(max-width: 767px), (pointer: coarse)');
165
+ const viewport = window.visualViewport;
166
+ const anchor = document.createElement('span');
167
+ anchor.style.cssText = 'position:fixed;width:0;height:0;pointer-events:none;visibility:hidden';
168
+ anchor.setAttribute('aria-hidden', 'true');
169
+ document.body.appendChild(anchor);
170
+ mobile = media.matches;
171
+
172
+ // A portal leaves the host wrapper, where apps map their own theme to editor tokens.
173
+ const themeTokens = [
174
+ '--border', '--background', '--foreground', '--popover', '--muted',
175
+ '--muted-foreground', '--primary', '--primary-foreground', '--accent',
176
+ '--accent-foreground', '--ring', '--hce-menu-z', '--hce-menu-surface'
177
+ ];
178
+ const syncTheme = () => {
179
+ const hostStyle = getComputedStyle(editor.view.dom);
180
+ for (const token of themeTokens) {
181
+ const value = mobile ? hostStyle.getPropertyValue(token) : '';
182
+ if (value) element.style.setProperty(token, value);
183
+ else element.style.removeProperty(token);
184
+ }
185
+ };
186
+ const syncViewport = () => {
187
+ syncTheme();
188
+ const width = viewport?.width ?? window.innerWidth;
189
+ const height = viewport?.height ?? window.innerHeight;
190
+ const left = viewport?.offsetLeft ?? 0;
191
+ const top = viewport?.offsetTop ?? 0;
192
+ anchor.style.left = `${left + width / 2}px`;
193
+ anchor.style.top = `${top + height}px`;
194
+ element.style.maxWidth = mobile ? `${Math.max(0, width - 16)}px` : '';
195
+ element.style.setProperty('--bubble-popup-height', `${Math.max(44, height - element.getBoundingClientRect().height - 24)}px`);
196
+ };
197
+ syncViewport();
198
+
199
+ const positionOptions = () => mobile
200
+ ? { strategy: 'fixed' as const, placement: 'top' as const, flip: false, shift: false, offset: 8 }
201
+ : { strategy: 'absolute' as const, placement: 'top' as const, flip: true, shift: true, offset: 8 };
202
+ const updatePosition = () => {
203
+ if (editor.isDestroyed) return;
204
+ syncViewport();
205
+ editor.view.dispatch(editor.state.tr.setMeta(bubbleToolbarKey, 'updatePosition'));
206
+ };
207
+ const updateMode = () => {
208
+ mobile = media.matches;
209
+ syncViewport();
210
+ editor.view.dispatch(editor.state.tr.setMeta(bubbleToolbarKey, {
211
+ type: 'updateOptions', options: { options: positionOptions() }
212
+ }));
213
+ if (element.isConnected) {
214
+ (mobile ? document.body : editor.view.dom.parentElement)?.appendChild(element);
215
+ }
216
+ updatePosition();
217
+ };
218
+ // Prevent button focus from collapsing the text selection; native OS menus remain enabled.
219
+ const preserveSelection = (event: MouseEvent) => {
220
+ if (mobile && (event.target as HTMLElement).closest('button')) event.preventDefault();
221
+ };
146
222
  const plugin = BubbleMenuPlugin({
147
223
  pluginKey: bubbleToolbarKey,
148
224
  editor,
149
- element: menuEl,
225
+ element,
226
+ appendTo: () => mobile ? document.body : editor.view.dom.parentElement!,
227
+ getReferencedVirtualElement: () => mobile ? anchor : null,
150
228
  shouldShow: ({ editor: e, state }) => {
151
229
  const { from, to } = state.selection;
230
+ if (!e.isEditable || (!e.view.hasFocus() && !element.contains(document.activeElement))) return false;
152
231
  if (from === to) return false;
153
232
  /*
154
233
  * ⚠️ **덩어리를 고른 것(NodeSelection)에는 뜨지 않는다.**
@@ -193,18 +272,36 @@
193
272
  * 마침 v3 기본값이 `placement: "top"` + `flip`·`shift` 활성이라 화면상 차이는 없었지만,
194
273
  * 죽은 설정을 남겨 두면 "위치를 지정해 뒀다"고 착각하게 된다.
195
274
  */
196
- options: {
197
- placement: "top",
198
- },
275
+ options: { ...positionOptions(), onShow: syncTheme },
199
276
  });
200
277
 
201
278
  editor.registerPlugin(plugin);
202
- document.addEventListener("mousedown", handleDocClick);
279
+ const themeObserver = new MutationObserver(syncTheme);
280
+ themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
281
+ themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
282
+ const observer = new ResizeObserver(updatePosition);
283
+ observer.observe(element);
284
+ media.addEventListener('change', updateMode);
285
+ viewport?.addEventListener('resize', updatePosition);
286
+ viewport?.addEventListener('scroll', updatePosition);
287
+ window.addEventListener('resize', updatePosition);
288
+ element.addEventListener('mousedown', preserveSelection);
289
+ document.addEventListener("pointerdown", handleDocClick);
290
+ document.addEventListener("focusin", handleDocClick);
203
291
  document.addEventListener("keydown", handleKeydown);
204
292
 
205
293
  return () => {
206
294
  editor.unregisterPlugin(bubbleToolbarKey);
207
- document.removeEventListener("mousedown", handleDocClick);
295
+ observer.disconnect();
296
+ themeObserver.disconnect();
297
+ anchor.remove();
298
+ media.removeEventListener('change', updateMode);
299
+ viewport?.removeEventListener('resize', updatePosition);
300
+ viewport?.removeEventListener('scroll', updatePosition);
301
+ window.removeEventListener('resize', updatePosition);
302
+ element.removeEventListener('mousedown', preserveSelection);
303
+ document.removeEventListener("pointerdown", handleDocClick);
304
+ document.removeEventListener("focusin", handleDocClick);
208
305
  document.removeEventListener("keydown", handleKeydown);
209
306
  };
210
307
  });
@@ -230,14 +327,16 @@
230
327
  );
231
328
  </script>
232
329
 
233
- <div bind:this={menuEl} class="bubble-toolbar-container" style="visibility: hidden">
330
+ <div bind:this={menuEl} class="bubble-toolbar-container" class:mobile style="visibility: hidden">
234
331
  <div class="flex items-center gap-0.5 px-1.5 py-1 rounded-full hce-floating-panel">
235
332
  {#if hasBlockMenu}
236
333
  <!-- Block type selector -->
237
- <div class="relative" bind:this={blockMenuEl}>
334
+ <div class="relative bubble-popover-anchor" bind:this={blockMenuEl}>
238
335
  <button
239
336
  type="button"
240
337
  onclick={() => (showBlockMenu = !showBlockMenu)}
338
+ aria-haspopup="menu"
339
+ aria-expanded={showBlockMenu}
241
340
  class="flex items-center gap-1 px-2.5 py-1.5 rounded-full text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
242
341
  >
243
342
  <Type size={12} />
@@ -246,7 +345,7 @@
246
345
  </button>
247
346
  {#if showBlockMenu}
248
347
  <div
249
- class="absolute bottom-full left-0 mb-1 bg-popover border border-border rounded-lg shadow-xl py-1"
348
+ class="bubble-popover absolute bottom-full left-0 mb-1 bg-popover border border-border rounded-lg shadow-xl py-1"
250
349
  style="min-width: 140px"
251
350
  onmousedown={(e) => e.preventDefault()}
252
351
  role="menu"
@@ -265,9 +364,9 @@
265
364
  showBlockMenu = false;
266
365
  }}
267
366
  >
268
- <Type size={12} /> 본문
367
+ <Type size={12} /> {t('paragraph')}
269
368
  </button>
270
- {#each [1, 2, 3] as level}
369
+ {#each [1, 2, 3] as level (level)}
271
370
  {#if has(level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3')}
272
371
  {@const Icon = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3}
273
372
  <button
@@ -287,7 +386,7 @@
287
386
  showBlockMenu = false;
288
387
  }}
289
388
  >
290
- <Icon size={12} /> 제목 {level}
389
+ <Icon size={12} /> {t(level === 1 ? 'heading1' : level === 2 ? 'heading2' : 'heading3')}
291
390
  </button>
292
391
  {/if}
293
392
  {/each}
@@ -305,7 +404,7 @@
305
404
  showBlockMenu = false;
306
405
  }}
307
406
  >
308
- <List size={12} /> 글머리 목록
407
+ <List size={12} /> {t('bulletList')}
309
408
  </button>
310
409
  {/if}
311
410
  {#if has('ordered-list')}
@@ -322,7 +421,7 @@
322
421
  showBlockMenu = false;
323
422
  }}
324
423
  >
325
- <ListOrdered size={12} /> 번호 목록
424
+ <ListOrdered size={12} /> {t('orderedList')}
326
425
  </button>
327
426
  {/if}
328
427
  {#if has('checklist')}
@@ -339,7 +438,7 @@
339
438
  showBlockMenu = false;
340
439
  }}
341
440
  >
342
- <ListChecks size={12} /> 체크리스트
441
+ <ListChecks size={12} /> {t('taskList')}
343
442
  </button>
344
443
  {/if}
345
444
  {#if has('blockquote')}
@@ -356,14 +455,14 @@
356
455
  showBlockMenu = false;
357
456
  }}
358
457
  >
359
- <Quote size={12} /> 인용문
458
+ <Quote size={12} /> {t('blockquote')}
360
459
  </button>
361
460
  {/if}
362
461
  </div>
363
462
  {/if}
364
463
  </div>
365
464
 
366
- <div class="w-px h-5 bg-border mx-0.5"></div>
465
+ <div class="bubble-divider w-px h-5 bg-border mx-0.5"></div>
367
466
  {/if}
368
467
 
369
468
  <!-- Format buttons -->
@@ -371,7 +470,7 @@
371
470
  <button
372
471
  type="button"
373
472
  onclick={() => editor.chain().focus().toggleBold().run()}
374
- aria-label="굵게"
473
+ aria-label={t('bold')}
375
474
  class={cn(
376
475
  "p-1.5 rounded-full transition-colors",
377
476
  isActive("bold")
@@ -386,7 +485,7 @@
386
485
  <button
387
486
  type="button"
388
487
  onclick={() => editor.chain().focus().toggleItalic().run()}
389
- aria-label="기울임"
488
+ aria-label={t('italic')}
390
489
  class={cn(
391
490
  "p-1.5 rounded-full transition-colors",
392
491
  isActive("italic")
@@ -401,7 +500,7 @@
401
500
  <button
402
501
  type="button"
403
502
  onclick={() => editor.chain().focus().toggleUnderline().run()}
404
- aria-label="밑줄"
503
+ aria-label={t('underline')}
405
504
  class={cn(
406
505
  "p-1.5 rounded-full transition-colors",
407
506
  isActive("underline")
@@ -416,7 +515,7 @@
416
515
  <button
417
516
  type="button"
418
517
  onclick={() => editor.chain().focus().toggleStrike().run()}
419
- aria-label="취소선"
518
+ aria-label={t('strike')}
420
519
  class={cn(
421
520
  "p-1.5 rounded-full transition-colors",
422
521
  isActive("strike")
@@ -433,7 +532,7 @@
433
532
  <button
434
533
  type="button"
435
534
  onclick={() => editor.chain().focus().toggleCode().run()}
436
- aria-label="코드"
535
+ aria-label={t('code')}
437
536
  class={cn(
438
537
  "p-1.5 rounded-full transition-colors",
439
538
  isActive("code")
@@ -454,7 +553,7 @@
454
553
  <button
455
554
  type="button"
456
555
  onclick={() => editor.chain().focus().toggleMathInline().run()}
457
- aria-label="인라인 수식"
556
+ aria-label={t('mathInline')}
458
557
  class={cn(
459
558
  "p-1.5 rounded-full transition-colors",
460
559
  isActive("math_inline")
@@ -467,14 +566,14 @@
467
566
  {/if}
468
567
 
469
568
  {#if has('highlight') || has('text-color')}
470
- <div class="w-px h-5 bg-border mx-0.5"></div>
569
+ <div class="bubble-divider w-px h-5 bg-border mx-0.5"></div>
471
570
  {/if}
472
571
 
473
572
  {#if has('highlight')}
474
573
  <button
475
574
  type="button"
476
575
  onclick={() => editor.chain().focus().toggleHighlight().run()}
477
- aria-label="하이라이트"
576
+ aria-label={t('highlight')}
478
577
  class={cn(
479
578
  "p-1.5 rounded-full transition-colors",
480
579
  isActive("highlight")
@@ -486,11 +585,13 @@
486
585
  </button>
487
586
  {/if}
488
587
  {#if has('text-color')}
489
- <div class="relative" bind:this={colorMenuEl}>
588
+ <div class="relative bubble-popover-anchor" bind:this={colorMenuEl}>
490
589
  <button
491
590
  type="button"
492
591
  onclick={() => (showColors = !showColors)}
493
- aria-label="글자색"
592
+ aria-haspopup="menu"
593
+ aria-expanded={showColors}
594
+ aria-label={t('textColor')}
494
595
  class={cn(
495
596
  "p-1.5 rounded-full transition-colors",
496
597
  editor.getAttributes("textStyle").color
@@ -502,17 +603,17 @@
502
603
  </button>
503
604
  {#if showColors}
504
605
  <div
505
- class="absolute bottom-full left-0 mb-1 bg-popover border border-border rounded-lg shadow-xl p-2"
606
+ class="bubble-popover absolute bottom-full left-0 mb-1 bg-popover border border-border rounded-lg shadow-xl p-2"
506
607
  style="min-width: 160px"
507
608
  onmousedown={(e) => e.preventDefault()}
508
609
  role="menu"
509
610
  tabindex="-1"
510
611
  >
511
612
  <div class="grid grid-cols-3 gap-1.5">
512
- {#each TEXT_COLORS as c}
613
+ {#each TEXT_COLORS as c (c.value)}
513
614
  <button
514
615
  type="button"
515
- title={c.label}
616
+ title={t(c.labelKey)}
516
617
  class="h-7 rounded-md border border-border transition-transform hover:scale-105 flex items-center justify-center text-xs font-bold"
517
618
  style="color: {c.value || '#000'}; background: #fff"
518
619
  onclick={() => {
@@ -531,7 +632,7 @@
531
632
  <label
532
633
  class="hce-color-divider mt-2 pt-2 flex items-center justify-between gap-2 px-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
533
634
  >
534
- <span>직접 선택</span>
635
+ <span>{t('customColor')}</span>
535
636
  <input
536
637
  type="color"
537
638
  class="h-6 w-10 cursor-pointer rounded border border-border bg-transparent p-0"
@@ -549,11 +650,11 @@
549
650
  {/if}
550
651
 
551
652
  {#if has('link')}
552
- <div class="w-px h-5 bg-border mx-0.5"></div>
653
+ <div class="bubble-divider w-px h-5 bg-border mx-0.5"></div>
553
654
  <button
554
655
  type="button"
555
656
  onclick={addLink}
556
- aria-label="링크"
657
+ aria-label={t('link')}
557
658
  class={cn(
558
659
  "p-1.5 rounded-full transition-colors",
559
660
  isActive("link")
@@ -566,3 +667,30 @@
566
667
  {/if}
567
668
  </div>
568
669
  </div>
670
+
671
+ <style>
672
+ .mobile .hce-floating-panel {
673
+ display: flex;
674
+ flex-wrap: wrap;
675
+ justify-content: center;
676
+ border-radius: 12px;
677
+ padding: 4px;
678
+ padding-bottom: max(4px, env(safe-area-inset-bottom));
679
+ }
680
+ .mobile button {
681
+ min-width: 44px;
682
+ min-height: 44px;
683
+ display: inline-flex;
684
+ align-items: center;
685
+ justify-content: center;
686
+ }
687
+ .mobile input[type="color"] { min-width: 44px; min-height: 44px; }
688
+ .mobile .bubble-divider { display: none; }
689
+ .mobile .bubble-popover-anchor { position: static; }
690
+ .mobile .bubble-popover {
691
+ left: 0;
692
+ right: 0;
693
+ max-height: min(var(--bubble-popup-height, 50dvh), 300px);
694
+ overflow-y: auto;
695
+ }
696
+ </style>
@@ -1,9 +1,12 @@
1
1
  import type { Editor } from "@tiptap/core";
2
2
  import type { ToolbarFeature, PromptHandler } from "../types";
3
+ import { type EditorTranslator } from "../i18n";
3
4
  type $$ComponentProps = {
4
5
  editor: Editor;
5
6
  features: Set<ToolbarFeature>;
6
7
  onPromptLink?: PromptHandler;
8
+ /** 에디터 UI 번역 함수. 미주입 시 ko. */
9
+ t?: EditorTranslator;
7
10
  };
8
11
  declare const BubbleToolbar: import("svelte").Component<$$ComponentProps, {}, "">;
9
12
  type BubbleToolbar = ReturnType<typeof BubbleToolbar>;
@@ -1 +1 @@
1
- {"version":3,"file":"BubbleToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/BubbleToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AA0B3C,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE7D,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B,CAAC;AA8bJ,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"BubbleToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/BubbleToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AA0B3C,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAC9D,OAAO,EAA4C,KAAK,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEzF,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,8BAA8B;IAC9B,CAAC,CAAC,EAAE,gBAAgB,CAAC;CACtB,CAAC;AA+hBJ,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}