@teriusu/rich-editor 0.10.7 → 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
@@ -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
  /*
@@ -72,27 +76,27 @@
72
76
  let colorMenuEl: HTMLDivElement | undefined = $state();
73
77
  const iconSize = 14;
74
78
 
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" }
79
+ const TEXT_COLORS: { labelKey: EditorMessageKey; value: string }[] = [
80
+ { labelKey: "colorDefault", value: "" },
81
+ { labelKey: "colorBlack", value: "#000000" },
82
+ { labelKey: "colorGray", value: "#6b7280" },
83
+ { labelKey: "colorRed", value: "#dc2626" },
84
+ { labelKey: "colorOrange", value: "#ea580c" },
85
+ { labelKey: "colorYellow", value: "#ca8a04" },
86
+ { labelKey: "colorGreen", value: "#16a34a" },
87
+ { labelKey: "colorBlue", value: "#2563eb" },
88
+ { labelKey: "colorPurple", value: "#7c3aed" }
85
89
  ];
86
90
 
87
91
  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 "본문";
92
+ if (isActive("heading", { level: 1 })) return t("heading1");
93
+ if (isActive("heading", { level: 2 })) return t("heading2");
94
+ if (isActive("heading", { level: 3 })) return t("heading3");
95
+ if (isActive("bulletList")) return t("bulletList");
96
+ if (isActive("orderedList")) return t("orderedList");
97
+ if (isActive("taskList")) return t("taskList");
98
+ if (isActive("blockquote")) return t("blockquote");
99
+ return t("paragraph");
96
100
  }
97
101
 
98
102
  function isParagraphActive(): boolean {
@@ -109,7 +113,7 @@
109
113
  const previousUrl = editor.getAttributes("link").href || "";
110
114
  const url = onPromptLink
111
115
  ? await onPromptLink(previousUrl)
112
- : window.prompt("링크 URL을 입력하세요", previousUrl);
116
+ : window.prompt(t("promptLinkUrl"), previousUrl);
113
117
  if (url === null) return;
114
118
  if (url === "") {
115
119
  editor.chain().focus().extendMarkRange("link").unsetLink().run();
@@ -265,7 +269,7 @@
265
269
  showBlockMenu = false;
266
270
  }}
267
271
  >
268
- <Type size={12} /> 본문
272
+ <Type size={12} /> {t('paragraph')}
269
273
  </button>
270
274
  {#each [1, 2, 3] as level}
271
275
  {#if has(level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3')}
@@ -287,7 +291,7 @@
287
291
  showBlockMenu = false;
288
292
  }}
289
293
  >
290
- <Icon size={12} /> 제목 {level}
294
+ <Icon size={12} /> {t(level === 1 ? 'heading1' : level === 2 ? 'heading2' : 'heading3')}
291
295
  </button>
292
296
  {/if}
293
297
  {/each}
@@ -305,7 +309,7 @@
305
309
  showBlockMenu = false;
306
310
  }}
307
311
  >
308
- <List size={12} /> 글머리 목록
312
+ <List size={12} /> {t('bulletList')}
309
313
  </button>
310
314
  {/if}
311
315
  {#if has('ordered-list')}
@@ -322,7 +326,7 @@
322
326
  showBlockMenu = false;
323
327
  }}
324
328
  >
325
- <ListOrdered size={12} /> 번호 목록
329
+ <ListOrdered size={12} /> {t('orderedList')}
326
330
  </button>
327
331
  {/if}
328
332
  {#if has('checklist')}
@@ -339,7 +343,7 @@
339
343
  showBlockMenu = false;
340
344
  }}
341
345
  >
342
- <ListChecks size={12} /> 체크리스트
346
+ <ListChecks size={12} /> {t('taskList')}
343
347
  </button>
344
348
  {/if}
345
349
  {#if has('blockquote')}
@@ -356,7 +360,7 @@
356
360
  showBlockMenu = false;
357
361
  }}
358
362
  >
359
- <Quote size={12} /> 인용문
363
+ <Quote size={12} /> {t('blockquote')}
360
364
  </button>
361
365
  {/if}
362
366
  </div>
@@ -371,7 +375,7 @@
371
375
  <button
372
376
  type="button"
373
377
  onclick={() => editor.chain().focus().toggleBold().run()}
374
- aria-label="굵게"
378
+ aria-label={t('bold')}
375
379
  class={cn(
376
380
  "p-1.5 rounded-full transition-colors",
377
381
  isActive("bold")
@@ -386,7 +390,7 @@
386
390
  <button
387
391
  type="button"
388
392
  onclick={() => editor.chain().focus().toggleItalic().run()}
389
- aria-label="기울임"
393
+ aria-label={t('italic')}
390
394
  class={cn(
391
395
  "p-1.5 rounded-full transition-colors",
392
396
  isActive("italic")
@@ -401,7 +405,7 @@
401
405
  <button
402
406
  type="button"
403
407
  onclick={() => editor.chain().focus().toggleUnderline().run()}
404
- aria-label="밑줄"
408
+ aria-label={t('underline')}
405
409
  class={cn(
406
410
  "p-1.5 rounded-full transition-colors",
407
411
  isActive("underline")
@@ -416,7 +420,7 @@
416
420
  <button
417
421
  type="button"
418
422
  onclick={() => editor.chain().focus().toggleStrike().run()}
419
- aria-label="취소선"
423
+ aria-label={t('strike')}
420
424
  class={cn(
421
425
  "p-1.5 rounded-full transition-colors",
422
426
  isActive("strike")
@@ -433,7 +437,7 @@
433
437
  <button
434
438
  type="button"
435
439
  onclick={() => editor.chain().focus().toggleCode().run()}
436
- aria-label="코드"
440
+ aria-label={t('code')}
437
441
  class={cn(
438
442
  "p-1.5 rounded-full transition-colors",
439
443
  isActive("code")
@@ -454,7 +458,7 @@
454
458
  <button
455
459
  type="button"
456
460
  onclick={() => editor.chain().focus().toggleMathInline().run()}
457
- aria-label="인라인 수식"
461
+ aria-label={t('mathInline')}
458
462
  class={cn(
459
463
  "p-1.5 rounded-full transition-colors",
460
464
  isActive("math_inline")
@@ -474,7 +478,7 @@
474
478
  <button
475
479
  type="button"
476
480
  onclick={() => editor.chain().focus().toggleHighlight().run()}
477
- aria-label="하이라이트"
481
+ aria-label={t('highlight')}
478
482
  class={cn(
479
483
  "p-1.5 rounded-full transition-colors",
480
484
  isActive("highlight")
@@ -490,7 +494,7 @@
490
494
  <button
491
495
  type="button"
492
496
  onclick={() => (showColors = !showColors)}
493
- aria-label="글자색"
497
+ aria-label={t('textColor')}
494
498
  class={cn(
495
499
  "p-1.5 rounded-full transition-colors",
496
500
  editor.getAttributes("textStyle").color
@@ -512,7 +516,7 @@
512
516
  {#each TEXT_COLORS as c}
513
517
  <button
514
518
  type="button"
515
- title={c.label}
519
+ title={t(c.labelKey)}
516
520
  class="h-7 rounded-md border border-border transition-transform hover:scale-105 flex items-center justify-center text-xs font-bold"
517
521
  style="color: {c.value || '#000'}; background: #fff"
518
522
  onclick={() => {
@@ -531,7 +535,7 @@
531
535
  <label
532
536
  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
537
  >
534
- <span>직접 선택</span>
538
+ <span>{t('customColor')}</span>
535
539
  <input
536
540
  type="color"
537
541
  class="h-6 w-10 cursor-pointer rounded border border-border bg-transparent p-0"
@@ -553,7 +557,7 @@
553
557
  <button
554
558
  type="button"
555
559
  onclick={addLink}
556
- aria-label="링크"
560
+ aria-label={t('link')}
557
561
  class={cn(
558
562
  "p-1.5 rounded-full transition-colors",
559
563
  isActive("link")
@@ -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;AAgcJ,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -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"}