@teriusu/rich-editor 0.9.6 → 0.9.7

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 (53) hide show
  1. package/dist/components/BubbleToolbar.svelte +102 -49
  2. package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -1
  3. package/dist/components/FixedToolbar.svelte +136 -214
  4. package/dist/components/FixedToolbar.svelte.d.ts +9 -1
  5. package/dist/components/FixedToolbar.svelte.d.ts.map +1 -1
  6. package/dist/components/InputModal.svelte +1 -1
  7. package/dist/components/MathModal.svelte +111 -1
  8. package/dist/components/MathModal.svelte.d.ts.map +1 -1
  9. package/dist/components/MediaPickerModal.svelte +206 -0
  10. package/dist/components/MediaPickerModal.svelte.d.ts +18 -0
  11. package/dist/components/MediaPickerModal.svelte.d.ts.map +1 -0
  12. package/dist/components/SlashCommandMenu.svelte +114 -13
  13. package/dist/components/SlashCommandMenu.svelte.d.ts +10 -1
  14. package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -1
  15. package/dist/components/TableBubbleMenu.svelte +71 -48
  16. package/dist/components/TableBubbleMenu.svelte.d.ts.map +1 -1
  17. package/dist/components/TipTapEditor.svelte +163 -24
  18. package/dist/components/TipTapEditor.svelte.d.ts +2 -0
  19. package/dist/components/TipTapEditor.svelte.d.ts.map +1 -1
  20. package/dist/extensions/NotionInputRules.d.ts +59 -0
  21. package/dist/extensions/NotionInputRules.d.ts.map +1 -0
  22. package/dist/extensions/NotionInputRules.js +141 -0
  23. package/dist/extensions/NotionInputRules.test.d.ts +2 -0
  24. package/dist/extensions/NotionInputRules.test.d.ts.map +1 -0
  25. package/dist/extensions/NotionInputRules.test.js +117 -0
  26. package/dist/extensions/VideoEmbed.d.ts +36 -0
  27. package/dist/extensions/VideoEmbed.d.ts.map +1 -0
  28. package/dist/extensions/VideoEmbed.js +246 -0
  29. package/dist/extensions/VideoEmbed.test.d.ts +2 -0
  30. package/dist/extensions/VideoEmbed.test.d.ts.map +1 -0
  31. package/dist/extensions/VideoEmbed.test.js +57 -0
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +13 -0
  35. package/dist/styles/editor.css +1 -1
  36. package/dist/types.d.ts +14 -2
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/types.js +20 -6
  39. package/dist/utils/lowlight.d.ts +17 -0
  40. package/dist/utils/lowlight.d.ts.map +1 -0
  41. package/dist/utils/lowlight.js +106 -0
  42. package/dist/utils/lowlight.test.d.ts +2 -0
  43. package/dist/utils/lowlight.test.d.ts.map +1 -0
  44. package/dist/utils/lowlight.test.js +56 -0
  45. package/dist/utils/sanitize.d.ts.map +1 -1
  46. package/dist/utils/sanitize.js +14 -0
  47. package/dist/utils/splitCell.d.ts +20 -0
  48. package/dist/utils/splitCell.d.ts.map +1 -0
  49. package/dist/utils/splitCell.js +74 -0
  50. package/dist/utils/table.d.ts +22 -0
  51. package/dist/utils/table.d.ts.map +1 -0
  52. package/dist/utils/table.js +68 -0
  53. package/package.json +6 -1
@@ -9,7 +9,6 @@
9
9
  AlignLeft,
10
10
  AlignCenter,
11
11
  AlignRight,
12
- LinkIcon,
13
12
  Heading1,
14
13
  Heading2,
15
14
  Heading3,
@@ -31,12 +30,13 @@
31
30
  Columns3,
32
31
  SquareDashed,
33
32
  Tv,
33
+ Youtube,
34
34
  Plus,
35
35
  Pilcrow,
36
- Palette,
37
36
  Sigma,
38
37
  } from "lucide-svelte";
39
38
  import { cn } from "../utils/cn";
39
+ import { insertTableSized } from "../utils/table";
40
40
  import InputModal from "./InputModal.svelte";
41
41
  import type { ToolbarFeature, PromptHandler } from "../types";
42
42
 
@@ -44,19 +44,28 @@
44
44
  editor,
45
45
  features,
46
46
  onPdfClick,
47
+ onImageClick,
47
48
  onFileClick,
48
49
  onPromptLink,
49
- onPromptImage,
50
50
  onPromptMbus,
51
+ onPromptVideo,
51
52
  toolbarEnd,
52
53
  }: {
53
54
  editor: Editor;
54
55
  features: Set<ToolbarFeature>;
55
56
  onPdfClick: () => void;
57
+ /**
58
+ * 이미지 넣기. **툴바는 무엇을 넣을지 정하지 않는다** — 업로드/링크 탭 모달을 띄울지
59
+ * URL 만 물을지는 에디터가 정한다(`TipTapEditor` 의 `pickImage`).
60
+ * 예전엔 여기서 `onPromptImage` 를 직접 불러 URL 만 받았고, 그래서 툴바로는
61
+ * 내 컴퓨터의 그림을 넣을 방법이 없었다.
62
+ */
63
+ onImageClick: () => void;
56
64
  onFileClick?: () => void;
57
65
  onPromptLink?: PromptHandler;
58
- onPromptImage?: PromptHandler;
59
66
  onPromptMbus?: PromptHandler;
67
+ /** 영상 URL 프롬프트. 미제공 시 내장 InputModal 폴백 */
68
+ onPromptVideo?: PromptHandler;
60
69
  /**
61
70
  * 툴바 **오른쪽 끝**에 호스트가 끼워 넣는 조각(예: HTML ↔ 에디터 토글).
62
71
  *
@@ -99,8 +108,6 @@
99
108
  */
100
109
  const canDo = (fn: (c: ReturnType<Editor["can"]>) => boolean) =>
101
110
  (tick, fn(editor.can()));
102
- const attrOf = (name: string, key: string) =>
103
- (tick, editor.getAttributes(name)[key] as string | undefined);
104
111
 
105
112
  const has = (f: ToolbarFeature) => features.has(f);
106
113
 
@@ -108,61 +115,22 @@
108
115
 
109
116
  let blockMenuOpen = $state(false);
110
117
  let insertMenuOpen = $state(false);
111
- let colorMenuOpen = $state(false);
112
- let modalState: { type: "link" | "image" | "mbus" } | null = $state(null);
118
+ let modalState: { type: "mbus" | "video" } | null = $state(null);
113
119
  let blockMenuEl: HTMLDivElement | undefined = $state();
114
120
  let insertMenuEl: HTMLDivElement | undefined = $state();
115
- let colorMenuEl: HTMLDivElement | undefined = $state();
116
-
117
- /*
118
- * ⚠️ **`검정(#000000)` 은 뺐다.** `기본`(×, 색 지정 해제)과 눈으로 구별이 안 되는데
119
- * (본문 기본색이 거의 검정이다) 두 가지가 다 있으면 헷갈린다 — 사용자가 짚은 부분이다.
120
- *
121
- * 남기는 쪽은 **`기본`** 이다. `검정` 만 남기면 본문 기본색으로 **되돌릴 방법이 없어지고**,
122
- * 저장된 HTML 에 `#000000` 이 눌러앉는다. 그 글은 다크 모드에서 검정 글자가 되어
123
- * 안 보인다(코드패스가 `sanitizeBodyColors` 로 이 자국을 걷어내고 있는 이유다).
124
- *
125
- * `브랜드` 는 앱마다 다르므로 값을 고정하지 않고 `--primary` 를 읽는다 —
126
- * 정올과 코드패스가 각자 자기 색을 얻는다. 저장은 계산된 실제 색으로 한다
127
- * (`var(--primary)` 를 그대로 저장하면 앱 밖에서 렌더할 때 색이 사라진다).
128
- *
129
- * ⚠️ **아홉 개로 맞춰 둔다.** 팔레트가 `grid-cols-3` 라 여덟 개면 마지막 줄에 빈 칸이
130
- * 남아 덜 만든 것처럼 보인다(사용자 지적). `기본` + 색 여덟이 3×3 을 정확히 채운다.
131
- * 색은 색상환을 고르게 훑도록 골랐다 — 빨강·주황·노랑·초록·파랑에 브랜드(남보라)와
132
- * 그 사이를 메우는 분홍, 그리고 무채색 회색. 여기서 더 늘리려면 12개(3×4)로 가야 한다.
133
- */
134
- const TEXT_COLORS: { label: string; value: string; cssVar?: string }[] = [
135
- { label: "기본", value: "" },
136
- { label: "브랜드", value: "", cssVar: "--primary" },
137
- { label: "회색", value: "#6b7280" },
138
- { label: "빨강", value: "#dc2626" },
139
- { label: "주황", value: "#ea580c" },
140
- { label: "노랑", value: "#ca8a04" },
141
- { label: "초록", value: "#16a34a" },
142
- { label: "파랑", value: "#2563eb" },
143
- { label: "분홍", value: "#db2777" }
144
- ];
145
-
146
- /** `--primary` 같은 CSS 변수를 그 자리에서 실제 색 문자열로 바꾼다. */
147
- function resolveCssVar(el: HTMLElement, name: string): string {
148
- return getComputedStyle(el).getPropertyValue(name).trim();
149
- }
150
121
 
151
122
  $effect(() => {
152
- if (!blockMenuOpen && !insertMenuOpen && !colorMenuOpen) return;
123
+ if (!blockMenuOpen && !insertMenuOpen) return;
153
124
  function handleClick(e: MouseEvent) {
154
125
  if (blockMenuEl && !blockMenuEl.contains(e.target as Node))
155
126
  blockMenuOpen = false;
156
127
  if (insertMenuEl && !insertMenuEl.contains(e.target as Node))
157
128
  insertMenuOpen = false;
158
- if (colorMenuEl && !colorMenuEl.contains(e.target as Node))
159
- colorMenuOpen = false;
160
129
  }
161
130
  function handleKeyDown(e: KeyboardEvent) {
162
131
  if (e.key === "Escape") {
163
132
  blockMenuOpen = false;
164
133
  insertMenuOpen = false;
165
- colorMenuOpen = false;
166
134
  }
167
135
  }
168
136
  document.addEventListener("mousedown", handleClick);
@@ -173,38 +141,20 @@
173
141
  };
174
142
  });
175
143
 
176
- async function addLink() {
144
+ function addImage() {
177
145
  insertMenuOpen = false;
178
- if (onPromptLink) {
179
- const previous = isActive("link")
180
- ? (editor.getAttributes("link").href as string) || ""
181
- : "";
182
- const url = await onPromptLink(previous);
183
- if (url === null) return;
184
- if (url === "") {
185
- editor.chain().focus().extendMarkRange("link").unsetLink().run();
186
- } else {
187
- editor
188
- .chain()
189
- .focus()
190
- .extendMarkRange("link")
191
- .setLink({ href: url })
192
- .run();
193
- }
194
- return;
195
- }
196
- modalState = { type: "link" };
146
+ onImageClick();
197
147
  }
198
148
 
199
- async function addImage() {
149
+ async function addVideo() {
200
150
  insertMenuOpen = false;
201
- if (onPromptImage) {
202
- const url = await onPromptImage("");
151
+ if (onPromptVideo) {
152
+ const url = await onPromptVideo("");
203
153
  if (!url) return;
204
- editor.chain().focus().setImage({ src: url }).run();
154
+ editor.chain().focus().setVideoEmbed({ src: url }).run();
205
155
  return;
206
156
  }
207
- modalState = { type: "image" };
157
+ modalState = { type: "video" };
208
158
  }
209
159
 
210
160
  async function addMbus() {
@@ -226,7 +176,13 @@
226
176
  if (isActive("orderedList")) return "번호 목록";
227
177
  if (isActive("taskList")) return "체크리스트";
228
178
  if (isActive("blockquote")) return "인용문";
229
- if (isActive("details")) return "토글";
179
+ if (isActive("details")) {
180
+ // 토글 제목이면 단계까지 보여 준다 — 그냥 "토글" 이면 무엇을 고른 건지 안 보인다.
181
+ for (const lv of [1, 2, 3]) {
182
+ if (isActive("detailsSummary", { level: lv })) return `토글 제목 ${lv}`;
183
+ }
184
+ return "토글";
185
+ }
230
186
  return "본문";
231
187
  });
232
188
 
@@ -240,12 +196,13 @@
240
196
  insertMenuOpen = false;
241
197
  }
242
198
 
199
+ /* ⚠️ `link` 는 여기 없다 — 인라인 서식 그룹으로 옮겼다(위 주석 참고). */
243
200
  const hasInsertItems = $derived(
244
201
  has("image") ||
245
- has("link") ||
246
202
  has("pdf") ||
247
203
  has("file") ||
248
204
  has("mbus") ||
205
+ has("video") ||
249
206
  has("table") ||
250
207
  has("columns-2") ||
251
208
  has("columns-3") ||
@@ -278,7 +235,6 @@
278
235
  type="button"
279
236
  onclick={() => editor.chain().focus().undo().run()}
280
237
  disabled={!canDo((c) => c.undo())}
281
- data-tooltip="실행 취소"
282
238
  aria-label="실행 취소"
283
239
  class={cn(
284
240
  "p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground",
@@ -293,7 +249,6 @@
293
249
  type="button"
294
250
  onclick={() => editor.chain().focus().redo().run()}
295
251
  disabled={!canDo((c) => c.redo())}
296
- data-tooltip="다시 실행"
297
252
  aria-label="다시 실행"
298
253
  class={cn(
299
254
  "p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground",
@@ -313,7 +268,6 @@
313
268
  <button
314
269
  type="button"
315
270
  onclick={() => (blockMenuOpen = !blockMenuOpen)}
316
- data-tooltip="블록 타입"
317
271
  aria-label="블록 타입"
318
272
  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]"
319
273
  >
@@ -323,7 +277,7 @@
323
277
  {#if blockMenuOpen}
324
278
  <div
325
279
  class="absolute top-full left-0 mt-1 bg-popover border border-border rounded-lg shadow-lg z-50 py-1"
326
- style="min-width: 200px"
280
+ style="min-width: 236px"
327
281
  onmousedown={(e) => e.preventDefault()}
328
282
  role="menu"
329
283
  tabindex="-1"
@@ -359,6 +313,7 @@
359
313
  )}
360
314
  >
361
315
  <Heading1 size={14} /> 제목 1
316
+ <span class="hce-menu-shortcut"># </span>
362
317
  </button>
363
318
  {/if}
364
319
  {#if has('h2')}
@@ -374,6 +329,7 @@
374
329
  )}
375
330
  >
376
331
  <Heading2 size={14} /> 제목 2
332
+ <span class="hce-menu-shortcut">## </span>
377
333
  </button>
378
334
  {/if}
379
335
  {#if has('h3')}
@@ -389,6 +345,7 @@
389
345
  )}
390
346
  >
391
347
  <Heading3 size={14} /> 제목 3
348
+ <span class="hce-menu-shortcut">### </span>
392
349
  </button>
393
350
  {/if}
394
351
  {#if has('bullet-list') || has('ordered-list') || has('checklist')}
@@ -405,6 +362,7 @@
405
362
  runBlock(() => editor.chain().focus().toggleBulletList().run())}
406
363
  >
407
364
  <List size={14} /> 글머리 목록
365
+ <span class="hce-menu-shortcut">- </span>
408
366
  </button>
409
367
  {/if}
410
368
  {#if has('ordered-list')}
@@ -418,6 +376,7 @@
418
376
  runBlock(() => editor.chain().focus().toggleOrderedList().run())}
419
377
  >
420
378
  <ListOrdered size={14} /> 번호 목록
379
+ <span class="hce-menu-shortcut">1. </span>
421
380
  </button>
422
381
  {/if}
423
382
  {#if has('checklist')}
@@ -431,6 +390,7 @@
431
390
  runBlock(() => editor.chain().focus().toggleTaskList().run())}
432
391
  >
433
392
  <CheckSquare size={14} /> 체크리스트
393
+ <span class="hce-menu-shortcut">[] </span>
434
394
  </button>
435
395
  {/if}
436
396
  {#if has('blockquote') || has('toggle')}
@@ -447,6 +407,7 @@
447
407
  runBlock(() => editor.chain().focus().toggleBlockquote().run())}
448
408
  >
449
409
  <Quote size={14} /> 인용문
410
+ <span class="hce-menu-shortcut">" </span>
450
411
  </button>
451
412
  {/if}
452
413
  {#if has('toggle')}
@@ -460,7 +421,34 @@
460
421
  runBlock(() => editor.chain().focus().setDetails().run())}
461
422
  >
462
423
  <ChevronRight size={14} /> 토글
424
+ <span class="hce-menu-shortcut">> </span>
463
425
  </button>
426
+ <!--
427
+ 토글 제목 — 접히는 제목. 만드는 일은 입력 규칙(`# > `)과 **같은 커맨드**가 한다.
428
+ 제목 바로 아래에 두는 게 맞지만 이 메뉴는 `본문 → 제목 → 목록 → 블록` 순이라,
429
+ 토글 옆에 붙여 **접히는 것끼리** 모은다.
430
+ -->
431
+ {#each [1, 2, 3] as level}
432
+ {@const Icon = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3}
433
+ <button
434
+ type="button"
435
+ class={cn(
436
+ "w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted",
437
+ isActive("detailsSummary", { level }) && "hce-active",
438
+ )}
439
+ onclick={() =>
440
+ runBlock(() =>
441
+ editor
442
+ .chain()
443
+ .focus()
444
+ .setToggleHeading(level as 1 | 2 | 3)
445
+ .run(),
446
+ )}
447
+ >
448
+ <Icon size={14} /> 토글 제목 {level}
449
+ <span class="hce-menu-shortcut">{'#'.repeat(level)} &gt; </span>
450
+ </button>
451
+ {/each}
464
452
  {/if}
465
453
  </div>
466
454
  {/if}
@@ -469,13 +457,19 @@
469
457
  {/if}
470
458
 
471
459
  {#if has('bold') || has('italic') || has('underline') || has('strike')}
472
- <!-- Inline marks (core 4) -->
460
+ <!--
461
+ 인라인 서식. **글자에 붙는 것은 전부 여기 모은다** — 굵게·기울임·밑줄·취소선에
462
+ `코드`(`<code>`)와 `링크`가 더해진다.
463
+
464
+ ⚠️ `링크` 는 원래 `삽입` 드롭다운에 있었다. 하지만 링크는 표·PDF 처럼 **새 덩어리를
465
+ 끼워 넣는 것**이 아니라 **선택한 글자에 씌우는 서식**이라, 쓰려면 글자를 골라 둔 상태에서
466
+ 메뉴를 두 번 여는 꼴이었다(사용자 요청으로 이동). 삽입 쪽에는 이제 없다.
467
+ -->
473
468
  <div class="hce-toolbar-group">
474
469
  {#if has('bold')}
475
470
  <button
476
471
  type="button"
477
472
  onclick={() => editor.chain().focus().toggleBold().run()}
478
- data-tooltip="굵게"
479
473
  aria-label="굵게"
480
474
  class={cn(
481
475
  "p-1.5 rounded-md transition-colors",
@@ -491,7 +485,6 @@
491
485
  <button
492
486
  type="button"
493
487
  onclick={() => editor.chain().focus().toggleItalic().run()}
494
- data-tooltip="기울임"
495
488
  aria-label="기울임"
496
489
  class={cn(
497
490
  "p-1.5 rounded-md transition-colors",
@@ -507,7 +500,6 @@
507
500
  <button
508
501
  type="button"
509
502
  onclick={() => editor.chain().focus().toggleUnderline().run()}
510
- data-tooltip="밑줄"
511
503
  aria-label="밑줄"
512
504
  class={cn(
513
505
  "p-1.5 rounded-md transition-colors",
@@ -523,7 +515,6 @@
523
515
  <button
524
516
  type="button"
525
517
  onclick={() => editor.chain().focus().toggleStrike().run()}
526
- data-tooltip="취소선"
527
518
  aria-label="취소선"
528
519
  class={cn(
529
520
  "p-1.5 rounded-md transition-colors",
@@ -535,104 +526,17 @@
535
526
  <Strikethrough size={iconSize} />
536
527
  </button>
537
528
  {/if}
538
- {#if has('math')}
539
529
  <!--
540
- 선택한 텍스트를 인라인 수식으로 감싸고, 이미 수식인 자리에서 누르면 원문으로 되돌린다
541
- (구세대 `setMath.js` 와 같은 토글). 선택이 없으면 감쌀 게 없으니 프롬프트로 새로 넣는다.
530
+ ⚠️ **`코드`·`링크`·`수식`·`글자색` 은 여기 없다 — 버블 툴바에 있다**(사용자 결정).
531
+
532
+ 이 넷은 **글자를 골라 놓고** 쓰는 것이라, 고르는 순간 뜨는 버블이 손에 더 가깝다.
533
+ 고정 툴바에도 두면 같은 버튼이 두 벌이 되고 인라인 그룹만 여덟 칸을 먹는다.
534
+ 남긴 `굵게·기울임·밑줄·취소선` 은 **선택 없이 켜 두고 이어 치는** 쓰임이 있어서
535
+ 늘 보이는 자리가 필요하다.
542
536
 
543
- ⚠️ **`run()` 의 반환값으로 갈라지면 안 된다.** `chain().focus().toggleMathInline().run()`
544
- 은 감싸기가 실제로 성공해도 false 를 돌려주는 경우가 있어(체인의 다른 단계가 false 를
545
- 섞는다), 감싸 놓고 프롬프트까지 같이 여는 걸 브라우저에서 확인했다.
546
- **무엇을 할지는 실행 전에 상태로 정한다.**
537
+ ⚠️ 빈 선택에서 **인라인 수식을 새로 넣던 길**은 이 버튼이 유일했으므로,
538
+ `삽입` 드롭다운으로 옮겨 두었다(아래 `인라인 수식`).
547
539
  -->
548
- <button
549
- type="button"
550
- onclick={() => {
551
- if (editor.state.selection.empty && !editor.isActive('math_inline')) {
552
- editor.chain().focus().promptMathInline().run();
553
- } else {
554
- editor.chain().focus().toggleMathInline().run();
555
- }
556
- }}
557
- data-tooltip="인라인 수식"
558
- aria-label="인라인 수식"
559
- class={cn(
560
- "p-1.5 rounded-md transition-colors",
561
- isActive("math_inline")
562
- ? "hce-active"
563
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
564
- )}
565
- >
566
- <Sigma size={iconSize} />
567
- </button>
568
- {/if}
569
- {#if has('text-color')}
570
- <div bind:this={colorMenuEl} class="relative">
571
- <button
572
- type="button"
573
- onclick={() => (colorMenuOpen = !colorMenuOpen)}
574
- data-tooltip="글자색"
575
- aria-label="글자색"
576
- class={cn(
577
- "flex items-center gap-0.5 p-1.5 rounded-md transition-colors",
578
- attrOf('textStyle', 'color')
579
- ? "hce-active"
580
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
581
- )}
582
- >
583
- <Palette size={iconSize} />
584
- <ChevronDown size={12} />
585
- </button>
586
- {#if colorMenuOpen}
587
- <div
588
- class="absolute top-full left-0 mt-1 bg-popover border border-border rounded-lg shadow-lg z-50 p-2"
589
- style="min-width: 180px"
590
- onmousedown={(e) => e.preventDefault()}
591
- role="menu"
592
- tabindex="-1"
593
- >
594
- <div class="grid grid-cols-3 gap-1.5">
595
- {#each TEXT_COLORS as c}
596
- <button
597
- type="button"
598
- title={c.label}
599
- class="h-8 rounded-md border border-border transition-transform hover:scale-105 flex items-center justify-center text-xs font-bold bg-background"
600
- style="color: {c.cssVar ? `var(${c.cssVar})` : c.value || '#6b7280'}"
601
- onclick={(e) => {
602
- const color = c.cssVar
603
- ? resolveCssVar(e.currentTarget as HTMLElement, c.cssVar)
604
- : c.value;
605
- if (color) {
606
- editor.chain().focus().setColor(color).run();
607
- } else {
608
- editor.chain().focus().unsetColor().run();
609
- }
610
- colorMenuOpen = false;
611
- }}
612
- >
613
- {c.cssVar || c.value ? "A" : "×"}
614
- </button>
615
- {/each}
616
- </div>
617
- <label
618
- class="mt-2 flex items-center justify-between gap-2 px-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
619
- >
620
- <span>직접 선택</span>
621
- <input
622
- type="color"
623
- class="h-6 w-10 cursor-pointer rounded border border-border bg-transparent p-0"
624
- value={attrOf('textStyle', 'color') || '#000000'}
625
- onclick={(e) => e.stopPropagation()}
626
- oninput={(e) => {
627
- const v = (e.target as HTMLInputElement).value;
628
- editor.chain().focus().setColor(v).run();
629
- }}
630
- />
631
- </label>
632
- </div>
633
- {/if}
634
- </div>
635
- {/if}
636
540
  </div>
637
541
  {/if}
638
542
 
@@ -643,7 +547,6 @@
643
547
  <button
644
548
  type="button"
645
549
  onclick={() => editor.chain().focus().setTextAlign('left').run()}
646
- data-tooltip="왼쪽 정렬"
647
550
  aria-label="왼쪽 정렬"
648
551
  class={cn(
649
552
  "p-1.5 rounded-md transition-colors",
@@ -659,7 +562,6 @@
659
562
  <button
660
563
  type="button"
661
564
  onclick={() => editor.chain().focus().setTextAlign('center').run()}
662
- data-tooltip="가운데 정렬"
663
565
  aria-label="가운데 정렬"
664
566
  class={cn(
665
567
  "p-1.5 rounded-md transition-colors",
@@ -675,7 +577,6 @@
675
577
  <button
676
578
  type="button"
677
579
  onclick={() => editor.chain().focus().setTextAlign('right').run()}
678
- data-tooltip="오른쪽 정렬"
679
580
  aria-label="오른쪽 정렬"
680
581
  class={cn(
681
582
  "p-1.5 rounded-md transition-colors",
@@ -697,7 +598,6 @@
697
598
  <button
698
599
  type="button"
699
600
  onclick={() => (insertMenuOpen = !insertMenuOpen)}
700
- data-tooltip="삽입"
701
601
  aria-label="삽입"
702
602
  class="flex items-center gap-1 px-2 py-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
703
603
  >
@@ -708,7 +608,7 @@
708
608
  {#if insertMenuOpen}
709
609
  <div
710
610
  class="absolute top-full left-0 mt-1 bg-popover border border-border rounded-lg shadow-lg z-50 py-1"
711
- style="min-width: 220px"
611
+ style="min-width: 236px"
712
612
  >
713
613
  {#if has('code-block')}
714
614
  <button
@@ -718,6 +618,7 @@
718
618
  runInsert(() => editor.chain().focus().setCodeBlock().run())}
719
619
  >
720
620
  <Code2 size={14} /> 코드 블록
621
+ <span class="hce-menu-shortcut">```</span>
721
622
  </button>
722
623
  {/if}
723
624
  {#if has('math')}
@@ -728,6 +629,22 @@
728
629
  runInsert(() => editor.chain().focus().promptMathDisplay().run())}
729
630
  >
730
631
  <Sigma size={14} /> 수식 블록
632
+ <span class="hce-menu-shortcut">$$</span>
633
+ </button>
634
+ <!--
635
+ ⚠️ **빈 선택에서 인라인 수식을 새로 넣는 유일한 길**이다.
636
+ 예전엔 인라인 그룹의 `Σ` 버튼이 그 일을 했는데(선택이 없으면 프롬프트를 열었다)
637
+ 그 버튼을 버블로 옮기면서 길이 끊겼다 — 버블은 뭔가 골라야만 뜨기 때문이다.
638
+ (`$…$` 를 쳐서 만드는 입력 규칙은 그대로 살아 있다.)
639
+ -->
640
+ <button
641
+ type="button"
642
+ class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
643
+ onclick={() =>
644
+ runInsert(() => editor.chain().focus().promptMathInline().run())}
645
+ >
646
+ <Sigma size={14} /> 인라인 수식
647
+ <span class="hce-menu-shortcut">$ $</span>
731
648
  </button>
732
649
  {/if}
733
650
  {#if has('pdf')}
@@ -745,7 +662,7 @@
745
662
  class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
746
663
  onclick={() => runInsert(onFileClick!)}
747
664
  >
748
- <Paperclip size={14} /> 파일 첨부
665
+ <Paperclip size={14} /> 파일
749
666
  </button>
750
667
  {/if}
751
668
  {#if has('table')}
@@ -754,11 +671,7 @@
754
671
  class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
755
672
  onclick={() =>
756
673
  runInsert(() =>
757
- editor
758
- .chain()
759
- .focus()
760
- .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
761
- .run(),
674
+ insertTableSized(editor, { rows: 3, cols: 3, withHeaderRow: true }),
762
675
  )}
763
676
  >
764
677
  <TableIcon size={14} /> 표 (3x3)
@@ -778,6 +691,7 @@
778
691
  )}
779
692
  >
780
693
  <Minus size={14} /> 구분선
694
+ <span class="hce-menu-shortcut">---</span>
781
695
  </button>
782
696
  {/if}
783
697
  {#if has('columns-2')}
@@ -810,7 +724,7 @@
810
724
  </button>
811
725
  {/if}
812
726
 
813
- {#if has('image') || has('link') || has('mbus')}
727
+ {#if has('image') || has('mbus') || has('video')}
814
728
  <div class="h-px bg-border my-1"></div>
815
729
  {/if}
816
730
  {#if has('image')}
@@ -822,13 +736,17 @@
822
736
  <ImageIcon size={14} /> 이미지
823
737
  </button>
824
738
  {/if}
825
- {#if has('link')}
739
+ {#if has('video')}
740
+ <!--
741
+ 유튜브·Vimeo 등 바깥 영상. 붙여넣은 주소를 임베드용으로 바꿔 주므로
742
+ `watch?v=…` 를 그대로 넣어도 된다(`extensions/VideoEmbed.ts`).
743
+ -->
826
744
  <button
827
745
  type="button"
828
746
  class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
829
- onclick={addLink}
747
+ onclick={addVideo}
830
748
  >
831
- <LinkIcon size={14} /> 링크
749
+ <Youtube size={14} /> 영상
832
750
  </button>
833
751
  {/if}
834
752
  {#if has('mbus')}
@@ -847,29 +765,13 @@
847
765
  {/if}
848
766
 
849
767
  <!-- Modals -->
850
- {#if modalState?.type === "link"}
851
- <InputModal
852
- title="링크 URL 입력"
853
- placeholder="https://example.com"
854
- defaultValue={isActive("link") ? editor.getAttributes("link").href || "" : ""}
855
- onConfirm={(url) => {
856
- editor
857
- .chain()
858
- .focus()
859
- .extendMarkRange("link")
860
- .setLink({ href: url })
861
- .run();
862
- modalState = null;
863
- }}
864
- onCancel={() => (modalState = null)}
865
- />
866
- {/if}
867
- {#if modalState?.type === "image"}
768
+ <!-- (이미지 모달은 없다 — 에디터가 업로드/링크 탭 모달을 띄운다. `onImageClick` 참고.) -->
769
+ {#if modalState?.type === "video"}
868
770
  <InputModal
869
- title="이미지 URL 입력"
870
- placeholder="https://example.com/image.png"
771
+ title="영상 URL"
772
+ placeholder="https://www.youtube.com/watch?v=..."
871
773
  onConfirm={(url) => {
872
- editor.chain().focus().setImage({ src: url }).run();
774
+ editor.chain().focus().setVideoEmbed({ src: url }).run();
873
775
  modalState = null;
874
776
  }}
875
777
  onCancel={() => (modalState = null)}
@@ -943,4 +845,24 @@
943
845
  .hce-toolbar-group :global(button) {
944
846
  flex-shrink: 0;
945
847
  }
848
+
849
+ /*
850
+ * 메뉴 항목 오른쪽의 입력 규칙 표시(사용자 요청). 메뉴를 한 번 쓰고 나면 다음부터는
851
+ * 쳐서 만들게 되는 게 목적이라, 눈에 띄되 이름을 가리지 않을 만큼만 흐리게 둔다.
852
+ *
853
+ * ⚠️ **드롭다운은 툴바 밖(`.bg-popover`)에 그려지므로 `:global` 이 필요하다.**
854
+ * ⚠️ `margin-left: auto` 로 오른쪽 끝에 붙이고 `flex-shrink: 0` 으로 지킨다 —
855
+ * 안 그러면 이름이 길 때 `# >` 쪽이 먼저 찌그러진다.
856
+ */
857
+ :global(.hce-menu-shortcut) {
858
+ margin-left: auto;
859
+ flex-shrink: 0;
860
+ padding-left: 10px;
861
+ font-size: 11px;
862
+ font-weight: 400;
863
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
864
+ color: var(--muted-foreground);
865
+ opacity: 0.75;
866
+ white-space: pre;
867
+ }
946
868
  </style>
@@ -5,10 +5,18 @@ type $$ComponentProps = {
5
5
  editor: Editor;
6
6
  features: Set<ToolbarFeature>;
7
7
  onPdfClick: () => void;
8
+ /**
9
+ * 이미지 넣기. **툴바는 무엇을 넣을지 정하지 않는다** — 업로드/링크 탭 모달을 띄울지
10
+ * URL 만 물을지는 에디터가 정한다(`TipTapEditor` 의 `pickImage`).
11
+ * 예전엔 여기서 `onPromptImage` 를 직접 불러 URL 만 받았고, 그래서 툴바로는
12
+ * 내 컴퓨터의 그림을 넣을 방법이 없었다.
13
+ */
14
+ onImageClick: () => void;
8
15
  onFileClick?: () => void;
9
16
  onPromptLink?: PromptHandler;
10
- onPromptImage?: PromptHandler;
11
17
  onPromptMbus?: PromptHandler;
18
+ /** 영상 URL 프롬프트. 미제공 시 내장 InputModal 폴백 */
19
+ onPromptVideo?: PromptHandler;
12
20
  /**
13
21
  * 툴바 **오른쪽 끝**에 호스트가 끼워 넣는 조각(예: HTML ↔ 에디터 토글).
14
22
  *
@@ -1 +1 @@
1
- {"version":3,"file":"FixedToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/FixedToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAsCtC,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,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AAqnBJ,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"FixedToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/FixedToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAsCtC,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,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB;;;;;OAKG;IACH,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,0CAA0C;IAC1C,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AAgiBJ,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}