@teriusu/rich-editor 0.7.1 → 0.7.3

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.
@@ -5,11 +5,10 @@
5
5
  Italic,
6
6
  Underline as UnderlineIcon,
7
7
  Strikethrough,
8
- Highlighter,
9
- LinkIcon,
10
8
  AlignLeft,
11
9
  AlignCenter,
12
10
  AlignRight,
11
+ LinkIcon,
13
12
  Heading1,
14
13
  Heading2,
15
14
  Heading3,
@@ -24,23 +23,14 @@
24
23
  Undo,
25
24
  Redo,
26
25
  Table as TableIcon,
27
- Rows3,
28
- Columns3,
29
- Trash2,
30
- SplitSquareHorizontal,
31
- Combine,
32
- PanelTop,
33
- PanelLeft,
34
- Paintbrush,
35
26
  CheckSquare,
36
- Superscript,
37
- Subscript,
38
- Video,
39
27
  ChevronRight,
40
28
  Paperclip,
41
- Film,
42
29
  Columns2,
30
+ Columns3,
43
31
  Tv,
32
+ Plus,
33
+ Pilcrow,
44
34
  Palette,
45
35
  } from "lucide-svelte";
46
36
  import { cn } from "../utils/cn";
@@ -52,7 +42,6 @@
52
42
  features,
53
43
  onPdfClick,
54
44
  onFileClick,
55
- onVideoClick,
56
45
  onPromptLink,
57
46
  onPromptImage,
58
47
  onPromptMbus,
@@ -61,7 +50,6 @@
61
50
  features: Set<ToolbarFeature>;
62
51
  onPdfClick: () => void;
63
52
  onFileClick?: () => void;
64
- onVideoClick?: () => void;
65
53
  onPromptLink?: PromptHandler;
66
54
  onPromptImage?: PromptHandler;
67
55
  onPromptMbus?: PromptHandler;
@@ -71,13 +59,12 @@
71
59
 
72
60
  const iconSize = 16;
73
61
 
74
- let codeMenuOpen = $state(false);
75
- let tableMenuOpen = $state(false);
62
+ let blockMenuOpen = $state(false);
63
+ let insertMenuOpen = $state(false);
76
64
  let colorMenuOpen = $state(false);
77
- let colorPickerActive = $state(false);
78
- let modalState: { type: "link" | "image" | "cellBg" | "mbus" } | null = $state(null);
79
- let codeMenuEl: HTMLDivElement | undefined = $state();
80
- let tableMenuEl: HTMLDivElement | undefined = $state();
65
+ let modalState: { type: "link" | "image" | "mbus" } | null = $state(null);
66
+ let blockMenuEl: HTMLDivElement | undefined = $state();
67
+ let insertMenuEl: HTMLDivElement | undefined = $state();
81
68
  let colorMenuEl: HTMLDivElement | undefined = $state();
82
69
 
83
70
  const TEXT_COLORS = [
@@ -93,23 +80,19 @@
93
80
  ];
94
81
 
95
82
  $effect(() => {
96
- if (!codeMenuOpen && !tableMenuOpen && !colorMenuOpen) return;
83
+ if (!blockMenuOpen && !insertMenuOpen && !colorMenuOpen) return;
97
84
  function handleClick(e: MouseEvent) {
98
- if (codeMenuEl && !codeMenuEl.contains(e.target as Node))
99
- codeMenuOpen = false;
100
- if (tableMenuEl && !tableMenuEl.contains(e.target as Node))
101
- tableMenuOpen = false;
102
- if (
103
- colorMenuEl &&
104
- !colorMenuEl.contains(e.target as Node) &&
105
- !colorPickerActive
106
- )
85
+ if (blockMenuEl && !blockMenuEl.contains(e.target as Node))
86
+ blockMenuOpen = false;
87
+ if (insertMenuEl && !insertMenuEl.contains(e.target as Node))
88
+ insertMenuOpen = false;
89
+ if (colorMenuEl && !colorMenuEl.contains(e.target as Node))
107
90
  colorMenuOpen = false;
108
91
  }
109
92
  function handleKeyDown(e: KeyboardEvent) {
110
93
  if (e.key === "Escape") {
111
- codeMenuOpen = false;
112
- tableMenuOpen = false;
94
+ blockMenuOpen = false;
95
+ insertMenuOpen = false;
113
96
  colorMenuOpen = false;
114
97
  }
115
98
  }
@@ -122,6 +105,7 @@
122
105
  });
123
106
 
124
107
  async function addLink() {
108
+ insertMenuOpen = false;
125
109
  if (onPromptLink) {
126
110
  const previous = editor.isActive("link")
127
111
  ? (editor.getAttributes("link").href as string) || ""
@@ -144,6 +128,7 @@
144
128
  }
145
129
 
146
130
  async function addImage() {
131
+ insertMenuOpen = false;
147
132
  if (onPromptImage) {
148
133
  const url = await onPromptImage("");
149
134
  if (!url) return;
@@ -153,851 +138,588 @@
153
138
  modalState = { type: "image" };
154
139
  }
155
140
 
156
- function runTableCommand(command: () => boolean) {
157
- command();
158
- tableMenuOpen = false;
141
+ async function addMbus() {
142
+ insertMenuOpen = false;
143
+ if (onPromptMbus) {
144
+ const url = await onPromptMbus("");
145
+ if (!url) return;
146
+ editor.chain().focus().setMbusVideo({ src: url }).run();
147
+ return;
148
+ }
149
+ modalState = { type: "mbus" };
159
150
  }
160
151
 
161
- const isInTable = $derived(editor.isActive("table"));
152
+ const currentBlockLabel = $derived.by(() => {
153
+ if (editor.isActive("heading", { level: 1 })) return "제목 1";
154
+ if (editor.isActive("heading", { level: 2 })) return "제목 2";
155
+ if (editor.isActive("heading", { level: 3 })) return "제목 3";
156
+ if (editor.isActive("bulletList")) return "글머리 목록";
157
+ if (editor.isActive("orderedList")) return "번호 목록";
158
+ if (editor.isActive("taskList")) return "체크리스트";
159
+ if (editor.isActive("blockquote")) return "인용문";
160
+ if (editor.isActive("details")) return "토글";
161
+ return "본문";
162
+ });
162
163
 
163
- const CELL_COLORS = [
164
- { label: "없음", value: "" },
165
- { label: "밝은 회색", value: "#f1f5f9" },
166
- { label: "밝은 파랑", value: "#dbeafe" },
167
- { label: "밝은 초록", value: "#dcfce7" },
168
- { label: "밝은 노랑", value: "#fef9c3" },
169
- { label: "밝은 주황", value: "#ffedd5" },
170
- { label: "밝은 빨강", value: "#fee2e2" },
171
- { label: "밝은 보라", value: "#ede9fe" },
172
- { label: "밝은 분홍", value: "#fce7f3" },
173
- ];
164
+ function runBlock(fn: () => void) {
165
+ fn();
166
+ blockMenuOpen = false;
167
+ }
168
+
169
+ function runInsert(fn: () => void) {
170
+ fn();
171
+ insertMenuOpen = false;
172
+ }
173
+
174
+ const hasInsertItems = $derived(
175
+ has("image") ||
176
+ has("link") ||
177
+ has("pdf") ||
178
+ has("file") ||
179
+ has("mbus") ||
180
+ has("table") ||
181
+ has("columns-2") ||
182
+ has("columns-3") ||
183
+ has("horizontal-rule") ||
184
+ has("code-block"),
185
+ );
186
+
187
+ const hasBlockItems = $derived(
188
+ has("h1") ||
189
+ has("h2") ||
190
+ has("h3") ||
191
+ has("bullet-list") ||
192
+ has("ordered-list") ||
193
+ has("checklist") ||
194
+ has("blockquote") ||
195
+ has("toggle"),
196
+ );
174
197
  </script>
175
198
 
176
199
  <div
177
200
  class="hce-toolbar sticky z-30 flex flex-wrap items-center gap-1.5 px-3 py-2 border-b border-border bg-background rounded-t-xl"
178
201
  style="top: var(--header-height, 74px)"
179
202
  >
180
- <!-- Format -->
203
+ {#if has('undo') || has('redo')}
204
+ <!-- Undo / Redo -->
181
205
  <div class="hce-toolbar-group">
182
- {#if has('bold')}
183
- <button
184
- type="button"
185
- onclick={() => editor.chain().focus().toggleBold().run()}
186
- data-tooltip="굵게"
187
- aria-label="굵게"
188
- class={cn(
189
- "p-1.5 rounded-md transition-colors",
190
- editor.isActive("bold")
191
- ? "bg-primary/10 text-primary"
192
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
193
- )}
194
- >
195
- <Bold size={iconSize} />
196
- </button>
197
- {/if}
198
- {#if has('italic')}
199
- <button
200
- type="button"
201
- onclick={() => editor.chain().focus().toggleItalic().run()}
202
- data-tooltip="기울임"
203
- aria-label="기울임"
204
- class={cn(
205
- "p-1.5 rounded-md transition-colors",
206
- editor.isActive("italic")
207
- ? "bg-primary/10 text-primary"
208
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
209
- )}
210
- >
211
- <Italic size={iconSize} />
212
- </button>
213
- {/if}
214
- {#if has('underline')}
215
- <button
216
- type="button"
217
- onclick={() => editor.chain().focus().toggleUnderline().run()}
218
- data-tooltip="밑줄"
219
- aria-label="밑줄"
220
- class={cn(
221
- "p-1.5 rounded-md transition-colors",
222
- editor.isActive("underline")
223
- ? "bg-primary/10 text-primary"
224
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
225
- )}
226
- >
227
- <UnderlineIcon size={iconSize} />
228
- </button>
229
- {/if}
230
- {#if has('strike')}
231
- <button
232
- type="button"
233
- onclick={() => editor.chain().focus().toggleStrike().run()}
234
- data-tooltip="취소선"
235
- aria-label="취소선"
236
- class={cn(
237
- "p-1.5 rounded-md transition-colors",
238
- editor.isActive("strike")
239
- ? "bg-primary/10 text-primary"
240
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
241
- )}
242
- >
243
- <Strikethrough size={iconSize} />
244
- </button>
245
- {/if}
246
- {#if has('highlight')}
247
- <button
248
- type="button"
249
- onclick={() => editor.chain().focus().toggleHighlight().run()}
250
- data-tooltip="하이라이트"
251
- aria-label="하이라이트"
252
- class={cn(
253
- "p-1.5 rounded-md transition-colors",
254
- editor.isActive("highlight")
255
- ? "bg-primary/10 text-primary"
256
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
257
- )}
258
- >
259
- <Highlighter size={iconSize} />
260
- </button>
261
- {/if}
262
- {#if has('superscript')}
263
- <button
264
- type="button"
265
- onclick={() => editor.chain().focus().toggleSuperscript().run()}
266
- data-tooltip="위첨자"
267
- aria-label="위첨자"
268
- class={cn(
269
- "p-1.5 rounded-md transition-colors",
270
- editor.isActive("superscript")
271
- ? "bg-primary/10 text-primary"
272
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
273
- )}
274
- >
275
- <Superscript size={iconSize} />
276
- </button>
277
- {/if}
278
- {#if has('subscript')}
279
- <button
280
- type="button"
281
- onclick={() => editor.chain().focus().toggleSubscript().run()}
282
- data-tooltip="아래첨자"
283
- aria-label="아래첨자"
284
- class={cn(
285
- "p-1.5 rounded-md transition-colors",
286
- editor.isActive("subscript")
287
- ? "bg-primary/10 text-primary"
288
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
289
- )}
290
- >
291
- <Subscript size={iconSize} />
292
- </button>
293
- {/if}
294
- {#if has('text-color')}
295
- <div bind:this={colorMenuEl} class="relative">
206
+ {#if has('undo')}
296
207
  <button
297
208
  type="button"
298
- onclick={() => (colorMenuOpen = !colorMenuOpen)}
299
- data-tooltip="글자색"
300
- aria-label="글자색"
209
+ onclick={() => editor.chain().focus().undo().run()}
210
+ disabled={!editor.can().undo()}
211
+ data-tooltip="실행 취소"
212
+ aria-label="실행 취소"
301
213
  class={cn(
302
- "flex items-center gap-0.5 p-1.5 rounded-md transition-colors",
303
- editor.getAttributes("textStyle").color
304
- ? "bg-primary/10 text-primary"
305
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
214
+ "p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground",
215
+ !editor.can().undo() && "opacity-30 pointer-events-none",
306
216
  )}
307
217
  >
308
- <Palette size={iconSize} />
309
- <ChevronDown size={12} />
218
+ <Undo size={iconSize} />
310
219
  </button>
311
- {#if colorMenuOpen}
312
- <div
313
- class="absolute top-full left-0 mt-1 bg-popover border border-border rounded-lg shadow-lg z-50 p-2"
314
- style="min-width: 180px"
315
- >
316
- <div class="grid grid-cols-3 gap-1.5">
317
- {#each TEXT_COLORS as c}
318
- <button
319
- type="button"
320
- title={c.label}
321
- class="h-8 rounded-md border border-border transition-transform hover:scale-105 flex items-center justify-center text-xs font-bold"
322
- style="color: {c.value || '#000'}"
323
- onclick={() => {
324
- if (c.value) {
325
- editor.chain().focus().setColor(c.value).run();
326
- } else {
327
- editor.chain().focus().unsetColor().run();
328
- }
329
- colorMenuOpen = false;
330
- }}
331
- >
332
- {c.value ? "A" : "×"}
333
- </button>
334
- {/each}
335
- </div>
336
- <label
337
- class="mt-2 flex items-center justify-between gap-2 px-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
338
- >
339
- <span>직접 선택</span>
340
- <input
341
- type="color"
342
- class="h-6 w-10 cursor-pointer rounded border border-border bg-transparent p-0"
343
- value={(editor.getAttributes("textStyle").color as string) || "#000000"}
344
- onpointerdown={() => (colorPickerActive = true)}
345
- onclick={(e) => e.stopPropagation()}
346
- oninput={(e) => {
347
- const v = (e.target as HTMLInputElement).value;
348
- editor.chain().focus().setColor(v).run();
349
- }}
350
- onchange={() => {
351
- setTimeout(() => (colorPickerActive = false), 200);
352
- }}
353
- onblur={() => {
354
- setTimeout(() => (colorPickerActive = false), 200);
355
- }}
356
- />
357
- </label>
358
- </div>
359
220
  {/if}
360
- </div>
361
- {/if}
362
-
363
- </div>
364
-
365
- {#if has('align-left') || has('align-center') || has('align-right')}
366
- <!-- Alignment -->
367
- <div class="hce-toolbar-group">
368
- {#if has('align-left')}
369
- <button
370
- type="button"
371
- onclick={() => editor.chain().focus().setTextAlign("left").run()}
372
- data-tooltip="왼쪽 정렬"
373
- aria-label="왼쪽 정렬"
374
- class={cn(
375
- "p-1.5 rounded-md transition-colors",
376
- editor.isActive({ textAlign: "left" })
377
- ? "bg-primary/10 text-primary"
378
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
379
- )}
380
- >
381
- <AlignLeft size={iconSize} />
382
- </button>
383
- {/if}
384
- {#if has('align-center')}
385
- <button
386
- type="button"
387
- onclick={() => editor.chain().focus().setTextAlign("center").run()}
388
- data-tooltip="가운데 정렬"
389
- aria-label="가운데 정렬"
390
- class={cn(
391
- "p-1.5 rounded-md transition-colors",
392
- editor.isActive({ textAlign: "center" })
393
- ? "bg-primary/10 text-primary"
394
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
395
- )}
396
- >
397
- <AlignCenter size={iconSize} />
398
- </button>
399
- {/if}
400
- {#if has('align-right')}
401
- <button
402
- type="button"
403
- onclick={() => editor.chain().focus().setTextAlign("right").run()}
404
- data-tooltip="오른쪽 정렬"
405
- aria-label="오른쪽 정렬"
406
- class={cn(
407
- "p-1.5 rounded-md transition-colors",
408
- editor.isActive({ textAlign: "right" })
409
- ? "bg-primary/10 text-primary"
410
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
411
- )}
412
- >
413
- <AlignRight size={iconSize} />
414
- </button>
415
- {/if}
416
-
417
- </div>
418
- {/if}
419
-
420
- {#if has('h1') || has('h2') || has('h3')}
421
- <!-- Headings -->
422
- <div class="hce-toolbar-group">
423
- {#if has('h1')}
424
- <button
425
- type="button"
426
- onclick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
427
- data-tooltip="제목 1"
428
- aria-label="제목 1"
429
- class={cn(
430
- "p-1.5 rounded-md transition-colors",
431
- editor.isActive("heading", { level: 1 })
432
- ? "bg-primary/10 text-primary"
433
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
434
- )}
435
- >
436
- <Heading1 size={iconSize} />
437
- </button>
438
- {/if}
439
- {#if has('h2')}
440
- <button
441
- type="button"
442
- onclick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
443
- data-tooltip="제목 2"
444
- aria-label="제목 2"
445
- class={cn(
446
- "p-1.5 rounded-md transition-colors",
447
- editor.isActive("heading", { level: 2 })
448
- ? "bg-primary/10 text-primary"
449
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
450
- )}
451
- >
452
- <Heading2 size={iconSize} />
453
- </button>
454
- {/if}
455
- {#if has('h3')}
456
- <button
457
- type="button"
458
- onclick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
459
- data-tooltip="제목 3"
460
- aria-label="제목 3"
461
- class={cn(
462
- "p-1.5 rounded-md transition-colors",
463
- editor.isActive("heading", { level: 3 })
464
- ? "bg-primary/10 text-primary"
465
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
466
- )}
467
- >
468
- <Heading3 size={iconSize} />
469
- </button>
470
- {/if}
471
-
472
- </div>
473
- {/if}
474
-
475
- {#if has('bullet-list') || has('ordered-list') || has('checklist') || has('blockquote') || has('horizontal-rule') || has('toggle')}
476
- <!-- Lists & Blocks -->
477
- <div class="hce-toolbar-group">
478
- {#if has('bullet-list')}
479
- <button
480
- type="button"
481
- onclick={() => editor.chain().focus().toggleBulletList().run()}
482
- data-tooltip="글머리 목록"
483
- aria-label="글머리 목록"
484
- class={cn(
485
- "p-1.5 rounded-md transition-colors",
486
- editor.isActive("bulletList")
487
- ? "bg-primary/10 text-primary"
488
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
489
- )}
490
- >
491
- <List size={iconSize} />
492
- </button>
493
- {/if}
494
- {#if has('ordered-list')}
495
- <button
496
- type="button"
497
- onclick={() => editor.chain().focus().toggleOrderedList().run()}
498
- data-tooltip="번호 목록"
499
- aria-label="번호 목록"
500
- class={cn(
501
- "p-1.5 rounded-md transition-colors",
502
- editor.isActive("orderedList")
503
- ? "bg-primary/10 text-primary"
504
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
505
- )}
506
- >
507
- <ListOrdered size={iconSize} />
508
- </button>
509
- {/if}
510
- {#if has('checklist')}
511
- <button
512
- type="button"
513
- onclick={() => editor.chain().focus().toggleTaskList().run()}
514
- data-tooltip="체크리스트"
515
- aria-label="체크리스트"
516
- class={cn(
517
- "p-1.5 rounded-md transition-colors",
518
- editor.isActive("taskList")
519
- ? "bg-primary/10 text-primary"
520
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
521
- )}
522
- >
523
- <CheckSquare size={iconSize} />
524
- </button>
525
- {/if}
526
- {#if has('blockquote')}
527
- <button
528
- type="button"
529
- onclick={() => editor.chain().focus().toggleBlockquote().run()}
530
- data-tooltip="인용문"
531
- aria-label="인용문"
532
- class={cn(
533
- "p-1.5 rounded-md transition-colors",
534
- editor.isActive("blockquote")
535
- ? "bg-primary/10 text-primary"
536
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
537
- )}
538
- >
539
- <Quote size={iconSize} />
540
- </button>
541
- {/if}
542
- {#if has('horizontal-rule')}
543
- <button
544
- type="button"
545
- onclick={() => editor.chain().focus().setHorizontalRule().run()}
546
- data-tooltip="구분선"
547
- aria-label="구분선"
548
- class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
549
- >
550
- <Minus size={iconSize} />
551
- </button>
552
- {/if}
553
- {#if has('toggle')}
554
- <button
555
- type="button"
556
- onclick={() => editor.chain().focus().setDetails().run()}
557
- data-tooltip="토글 (접기/펼치기)"
558
- aria-label="토글 (접기/펼치기)"
559
- class={cn(
560
- "p-1.5 rounded-md transition-colors",
561
- editor.isActive("details")
562
- ? "bg-primary/10 text-primary"
563
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
564
- )}
565
- >
566
- <ChevronRight size={iconSize} />
567
- </button>
568
- {/if}
569
-
570
- </div>
571
- {/if}
572
-
573
- {#if has('link') || has('image') || has('pdf') || has('youtube') || has('video') || has('file') || has('mbus') || has('columns-2') || has('columns-3') || has('table') || has('code-block')}
574
- <!-- Media & Layout -->
575
- <div class="hce-toolbar-group">
576
- {#if has('link')}
577
- <button
578
- type="button"
579
- onclick={addLink}
580
- data-tooltip="링크"
581
- aria-label="링크"
582
- class={cn(
583
- "p-1.5 rounded-md transition-colors",
584
- editor.isActive("link")
585
- ? "bg-primary/10 text-primary"
586
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
587
- )}
588
- >
589
- <LinkIcon size={iconSize} />
590
- </button>
591
- {/if}
592
- {#if has('image')}
593
- <button
594
- type="button"
595
- onclick={addImage}
596
- data-tooltip="이미지"
597
- aria-label="이미지"
598
- class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
599
- >
600
- <ImageIcon size={iconSize} />
601
- </button>
602
- {/if}
603
- {#if has('pdf')}
604
- <button
605
- type="button"
606
- onclick={onPdfClick}
607
- data-tooltip="PDF 삽입"
608
- aria-label="PDF 삽입"
609
- class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
610
- >
611
- <FileText size={iconSize} />
612
- </button>
613
- {/if}
614
- {#if has('youtube')}
615
- <button
616
- type="button"
617
- onclick={() => {
618
- const url = window.prompt("YouTube URL을 입력하세요");
619
- if (url) editor.chain().focus().setYoutubeVideo({ src: url }).run();
620
- }}
621
- data-tooltip="YouTube 영상"
622
- aria-label="YouTube 영상"
623
- class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
624
- >
625
- <Video size={iconSize} />
626
- </button>
627
- {/if}
628
- {#if has('mbus')}
221
+ {#if has('redo')}
629
222
  <button
630
223
  type="button"
631
- onclick={async () => {
632
- if (onPromptMbus) {
633
- const url = await onPromptMbus("");
634
- if (!url) return;
635
- editor.chain().focus().setMbusVideo({ src: url }).run();
636
- } else {
637
- modalState = { type: "mbus" };
638
- }
639
- }}
640
- data-tooltip="미디버스 영상"
641
- aria-label="미디버스 영상"
642
- class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
643
- >
644
- <Tv size={iconSize} />
645
- </button>
646
- {/if}
647
- {#if has('video') && onVideoClick}
648
- <button
649
- type="button"
650
- onclick={onVideoClick}
651
- data-tooltip="영상 파일"
652
- aria-label="영상 파일"
653
- class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
654
- >
655
- <Film size={iconSize} />
656
- </button>
657
- {/if}
658
- {#if has('file') && onFileClick}
659
- <button
660
- type="button"
661
- onclick={onFileClick}
662
- data-tooltip="파일 첨부"
663
- aria-label="파일 첨부"
664
- class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
665
- >
666
- <Paperclip size={iconSize} />
667
- </button>
668
- {/if}
669
-
670
- <!-- Columns -->
671
- {#if has('columns-2')}
672
- <button
673
- type="button"
674
- onclick={() => editor.chain().focus().setColumns(2).run()}
675
- data-tooltip="2단 컬럼"
676
- aria-label="2단 컬럼"
677
- class={cn(
678
- "p-1.5 rounded-md transition-colors",
679
- editor.isActive("columns")
680
- ? "bg-primary/10 text-primary"
681
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
682
- )}
683
- >
684
- <Columns2 size={iconSize} />
685
- </button>
686
- {/if}
687
- {#if has('columns-3')}
688
- <button
689
- type="button"
690
- onclick={() => editor.chain().focus().setColumns(3).run()}
691
- data-tooltip="3단 컬럼"
692
- aria-label="3단 컬럼"
693
- class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
694
- >
695
- <Columns3 size={iconSize} />
696
- </button>
697
- {/if}
698
-
699
- <!-- Table menu -->
700
- {#if has('table')}
701
- <div bind:this={tableMenuEl} class="relative">
702
- <button
703
- type="button"
704
- onclick={() => (tableMenuOpen = !tableMenuOpen)}
705
- data-tooltip="표"
706
- aria-label="표"
224
+ onclick={() => editor.chain().focus().redo().run()}
225
+ disabled={!editor.can().redo()}
226
+ data-tooltip="다시 실행"
227
+ aria-label="다시 실행"
707
228
  class={cn(
708
- "flex items-center gap-0.5 p-1.5 rounded-md transition-colors",
709
- isInTable
710
- ? "bg-primary/10 text-primary"
711
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
229
+ "p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground",
230
+ !editor.can().redo() && "opacity-30 pointer-events-none",
712
231
  )}
713
232
  >
714
- <TableIcon size={iconSize} />
715
- <ChevronDown size={12} />
233
+ <Redo size={iconSize} />
716
234
  </button>
717
- {#if tableMenuOpen}
718
- <div
719
- class="absolute top-full left-0 mt-1 bg-popover border border-border rounded-lg shadow-lg z-50 py-1"
720
- style="min-width: 220px"
235
+ {/if}
236
+ </div>
237
+ {/if}
238
+
239
+ {#if hasBlockItems}
240
+ <!-- Block type selector -->
241
+ <div class="hce-toolbar-group">
242
+ <div bind:this={blockMenuEl} class="relative">
243
+ <button
244
+ type="button"
245
+ onclick={() => (blockMenuOpen = !blockMenuOpen)}
246
+ data-tooltip="블록 타입"
247
+ aria-label="블록 타입"
248
+ 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]"
721
249
  >
722
- {#if !isInTable}
250
+ <span class="text-sm">{currentBlockLabel}</span>
251
+ <ChevronDown size={12} />
252
+ </button>
253
+ {#if blockMenuOpen}
254
+ <div
255
+ class="absolute top-full left-0 mt-1 bg-popover border border-border rounded-lg shadow-lg z-50 py-1"
256
+ style="min-width: 200px"
257
+ onmousedown={(e) => e.preventDefault()}
258
+ role="menu"
259
+ tabindex="-1"
260
+ >
723
261
  <button
724
262
  type="button"
725
- class="w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted"
263
+ class={cn(
264
+ "w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted",
265
+ !editor.isActive("heading") &&
266
+ !editor.isActive("bulletList") &&
267
+ !editor.isActive("orderedList") &&
268
+ !editor.isActive("taskList") &&
269
+ !editor.isActive("blockquote") &&
270
+ !editor.isActive("codeBlock") &&
271
+ !editor.isActive("details") &&
272
+ "bg-primary/10 text-primary",
273
+ )}
726
274
  onclick={() =>
727
- runTableCommand(() =>
728
- editor
729
- .chain()
730
- .focus()
731
- .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
732
- .run(),
733
- )}
275
+ runBlock(() => editor.chain().focus().setParagraph().run())}
734
276
  >
735
- <TableIcon size={14} />
736
- 표 삽입 (3x3)
277
+ <Pilcrow size={14} /> 본문
737
278
  </button>
279
+ {#if has('h1')}
738
280
  <button
739
281
  type="button"
740
- class="w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted"
282
+ class={cn(
283
+ "w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted",
284
+ editor.isActive("heading", { level: 1 }) && "bg-primary/10 text-primary",
285
+ )}
741
286
  onclick={() =>
742
- runTableCommand(() =>
743
- editor
744
- .chain()
745
- .focus()
746
- .insertTable({ rows: 4, cols: 4, withHeaderRow: true })
747
- .run(),
287
+ runBlock(() =>
288
+ editor.chain().focus().toggleHeading({ level: 1 }).run(),
748
289
  )}
749
290
  >
750
- <PanelTop size={14} />
751
- 헤더 포함 4x4
291
+ <Heading1 size={14} /> 제목 1
752
292
  </button>
753
- {:else}
754
- <p
755
- class="px-3 py-1 text-xs font-semibold text-muted-foreground"
756
- >
757
- 구조
758
- </p>
293
+ {/if}
294
+ {#if has('h2')}
759
295
  <button
760
296
  type="button"
761
- disabled={!editor.can().addRowBefore()}
762
297
  class={cn(
763
- "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
764
- !editor.can().addRowBefore() && "opacity-30 pointer-events-none",
298
+ "w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted",
299
+ editor.isActive("heading", { level: 2 }) && "bg-primary/10 text-primary",
765
300
  )}
766
301
  onclick={() =>
767
- runTableCommand(() =>
768
- editor.chain().focus().addRowBefore().run(),
302
+ runBlock(() =>
303
+ editor.chain().focus().toggleHeading({ level: 2 }).run(),
769
304
  )}
770
305
  >
771
- <Rows3 size={14} /> 행 위에 추가
306
+ <Heading2 size={14} /> 제목 2
772
307
  </button>
308
+ {/if}
309
+ {#if has('h3')}
773
310
  <button
774
311
  type="button"
775
- disabled={!editor.can().addRowAfter()}
776
312
  class={cn(
777
- "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
778
- !editor.can().addRowAfter() && "opacity-30 pointer-events-none",
313
+ "w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted",
314
+ editor.isActive("heading", { level: 3 }) && "bg-primary/10 text-primary",
779
315
  )}
780
316
  onclick={() =>
781
- runTableCommand(() =>
782
- editor.chain().focus().addRowAfter().run(),
317
+ runBlock(() =>
318
+ editor.chain().focus().toggleHeading({ level: 3 }).run(),
783
319
  )}
784
320
  >
785
- <Rows3 size={14} /> 행 아래에 추가
321
+ <Heading3 size={14} /> 제목 3
786
322
  </button>
323
+ {/if}
324
+ {#if has('bullet-list') || has('ordered-list') || has('checklist')}
325
+ <div class="h-px bg-border my-1"></div>
326
+ {/if}
327
+ {#if has('bullet-list')}
787
328
  <button
788
329
  type="button"
789
- disabled={!editor.can().addColumnBefore()}
790
330
  class={cn(
791
- "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
792
- !editor.can().addColumnBefore() &&
793
- "opacity-30 pointer-events-none",
331
+ "w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted",
332
+ editor.isActive("bulletList") && "bg-primary/10 text-primary",
794
333
  )}
795
334
  onclick={() =>
796
- runTableCommand(() =>
797
- editor.chain().focus().addColumnBefore().run(),
798
- )}
335
+ runBlock(() => editor.chain().focus().toggleBulletList().run())}
799
336
  >
800
- <Columns3 size={14} /> 열 왼쪽에 추가
337
+ <List size={14} /> 글머리 목록
801
338
  </button>
339
+ {/if}
340
+ {#if has('ordered-list')}
802
341
  <button
803
342
  type="button"
804
- disabled={!editor.can().addColumnAfter()}
805
343
  class={cn(
806
- "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
807
- !editor.can().addColumnAfter() &&
808
- "opacity-30 pointer-events-none",
344
+ "w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted",
345
+ editor.isActive("orderedList") && "bg-primary/10 text-primary",
809
346
  )}
810
347
  onclick={() =>
811
- runTableCommand(() =>
812
- editor.chain().focus().addColumnAfter().run(),
813
- )}
348
+ runBlock(() => editor.chain().focus().toggleOrderedList().run())}
814
349
  >
815
- <Columns3 size={14} /> 열 오른쪽에 추가
350
+ <ListOrdered size={14} /> 번호 목록
816
351
  </button>
817
- <div class="h-px bg-border my-1"></div>
818
- <p
819
- class="px-3 py-1 text-xs font-semibold text-muted-foreground"
820
- >
821
- 헤더 / 셀
822
- </p>
352
+ {/if}
353
+ {#if has('checklist')}
823
354
  <button
824
355
  type="button"
825
- disabled={!editor.can().toggleHeaderRow()}
826
356
  class={cn(
827
- "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
828
- !editor.can().toggleHeaderRow() &&
829
- "opacity-30 pointer-events-none",
357
+ "w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted",
358
+ editor.isActive("taskList") && "bg-primary/10 text-primary",
830
359
  )}
831
360
  onclick={() =>
832
- runTableCommand(() =>
833
- editor.chain().focus().toggleHeaderRow().run(),
834
- )}
361
+ runBlock(() => editor.chain().focus().toggleTaskList().run())}
835
362
  >
836
- <PanelTop size={14} /> 헤더 행 토글
363
+ <CheckSquare size={14} /> 체크리스트
837
364
  </button>
365
+ {/if}
366
+ {#if has('blockquote') || has('toggle')}
367
+ <div class="h-px bg-border my-1"></div>
368
+ {/if}
369
+ {#if has('blockquote')}
838
370
  <button
839
371
  type="button"
840
- disabled={!editor.can().toggleHeaderColumn()}
841
372
  class={cn(
842
- "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
843
- !editor.can().toggleHeaderColumn() &&
844
- "opacity-30 pointer-events-none",
373
+ "w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted",
374
+ editor.isActive("blockquote") && "bg-primary/10 text-primary",
845
375
  )}
846
376
  onclick={() =>
847
- runTableCommand(() =>
848
- editor.chain().focus().toggleHeaderColumn().run(),
849
- )}
377
+ runBlock(() => editor.chain().focus().toggleBlockquote().run())}
850
378
  >
851
- <PanelLeft size={14} /> 헤더 열 토글
379
+ <Quote size={14} /> 인용문
852
380
  </button>
381
+ {/if}
382
+ {#if has('toggle')}
853
383
  <button
854
384
  type="button"
855
- disabled={!editor.can().mergeCells()}
856
385
  class={cn(
857
- "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
858
- !editor.can().mergeCells() && "opacity-30 pointer-events-none",
386
+ "w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted",
387
+ editor.isActive("details") && "bg-primary/10 text-primary",
859
388
  )}
860
389
  onclick={() =>
861
- runTableCommand(() =>
862
- editor.chain().focus().mergeCells().run(),
863
- )}
390
+ runBlock(() => editor.chain().focus().setDetails().run())}
864
391
  >
865
- <Combine size={14} /> 셀 병합
392
+ <ChevronRight size={14} /> 토글
866
393
  </button>
394
+ {/if}
395
+ </div>
396
+ {/if}
397
+ </div>
398
+ </div>
399
+ {/if}
400
+
401
+ {#if has('bold') || has('italic') || has('underline') || has('strike')}
402
+ <!-- Inline marks (core 4) -->
403
+ <div class="hce-toolbar-group">
404
+ {#if has('bold')}
405
+ <button
406
+ type="button"
407
+ onclick={() => editor.chain().focus().toggleBold().run()}
408
+ data-tooltip="굵게"
409
+ aria-label="굵게"
410
+ class={cn(
411
+ "p-1.5 rounded-md transition-colors",
412
+ editor.isActive("bold")
413
+ ? "bg-primary/10 text-primary"
414
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
415
+ )}
416
+ >
417
+ <Bold size={iconSize} />
418
+ </button>
419
+ {/if}
420
+ {#if has('italic')}
421
+ <button
422
+ type="button"
423
+ onclick={() => editor.chain().focus().toggleItalic().run()}
424
+ data-tooltip="기울임"
425
+ aria-label="기울임"
426
+ class={cn(
427
+ "p-1.5 rounded-md transition-colors",
428
+ editor.isActive("italic")
429
+ ? "bg-primary/10 text-primary"
430
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
431
+ )}
432
+ >
433
+ <Italic size={iconSize} />
434
+ </button>
435
+ {/if}
436
+ {#if has('underline')}
437
+ <button
438
+ type="button"
439
+ onclick={() => editor.chain().focus().toggleUnderline().run()}
440
+ data-tooltip="밑줄"
441
+ aria-label="밑줄"
442
+ class={cn(
443
+ "p-1.5 rounded-md transition-colors",
444
+ editor.isActive("underline")
445
+ ? "bg-primary/10 text-primary"
446
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
447
+ )}
448
+ >
449
+ <UnderlineIcon size={iconSize} />
450
+ </button>
451
+ {/if}
452
+ {#if has('strike')}
453
+ <button
454
+ type="button"
455
+ onclick={() => editor.chain().focus().toggleStrike().run()}
456
+ data-tooltip="취소선"
457
+ aria-label="취소선"
458
+ class={cn(
459
+ "p-1.5 rounded-md transition-colors",
460
+ editor.isActive("strike")
461
+ ? "bg-primary/10 text-primary"
462
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
463
+ )}
464
+ >
465
+ <Strikethrough size={iconSize} />
466
+ </button>
467
+ {/if}
468
+ {#if has('text-color')}
469
+ <div bind:this={colorMenuEl} class="relative">
470
+ <button
471
+ type="button"
472
+ onclick={() => (colorMenuOpen = !colorMenuOpen)}
473
+ data-tooltip="글자색"
474
+ aria-label="글자색"
475
+ class={cn(
476
+ "flex items-center gap-0.5 p-1.5 rounded-md transition-colors",
477
+ editor.getAttributes('textStyle').color
478
+ ? "bg-primary/10 text-primary"
479
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
480
+ )}
481
+ >
482
+ <Palette size={iconSize} />
483
+ <ChevronDown size={12} />
484
+ </button>
485
+ {#if colorMenuOpen}
486
+ <div
487
+ class="absolute top-full left-0 mt-1 bg-popover border border-border rounded-lg shadow-lg z-50 p-2"
488
+ style="min-width: 180px"
489
+ onmousedown={(e) => e.preventDefault()}
490
+ role="menu"
491
+ tabindex="-1"
492
+ >
493
+ <div class="grid grid-cols-3 gap-1.5">
494
+ {#each TEXT_COLORS as c}
495
+ <button
496
+ type="button"
497
+ title={c.label}
498
+ class="h-8 rounded-md border border-border transition-transform hover:scale-105 flex items-center justify-center text-xs font-bold bg-background"
499
+ style="color: {c.value || '#6b7280'}"
500
+ onclick={() => {
501
+ if (c.value) {
502
+ editor.chain().focus().setColor(c.value).run();
503
+ } else {
504
+ editor.chain().focus().unsetColor().run();
505
+ }
506
+ colorMenuOpen = false;
507
+ }}
508
+ >
509
+ {c.value ? "A" : "×"}
510
+ </button>
511
+ {/each}
512
+ </div>
513
+ <label
514
+ class="mt-2 flex items-center justify-between gap-2 px-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
515
+ >
516
+ <span>직접 선택</span>
517
+ <input
518
+ type="color"
519
+ class="h-6 w-10 cursor-pointer rounded border border-border bg-transparent p-0"
520
+ value={(editor.getAttributes('textStyle').color as string) || '#000000'}
521
+ onclick={(e) => e.stopPropagation()}
522
+ oninput={(e) => {
523
+ const v = (e.target as HTMLInputElement).value;
524
+ editor.chain().focus().setColor(v).run();
525
+ }}
526
+ />
527
+ </label>
528
+ </div>
529
+ {/if}
530
+ </div>
531
+ {/if}
532
+ </div>
533
+ {/if}
534
+
535
+ {#if has('align-left') || has('align-center') || has('align-right')}
536
+ <!-- Alignment -->
537
+ <div class="hce-toolbar-group">
538
+ {#if has('align-left')}
539
+ <button
540
+ type="button"
541
+ onclick={() => editor.chain().focus().setTextAlign('left').run()}
542
+ data-tooltip="왼쪽 정렬"
543
+ aria-label="왼쪽 정렬"
544
+ class={cn(
545
+ "p-1.5 rounded-md transition-colors",
546
+ editor.isActive({ textAlign: 'left' })
547
+ ? "bg-primary/10 text-primary"
548
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
549
+ )}
550
+ >
551
+ <AlignLeft size={iconSize} />
552
+ </button>
553
+ {/if}
554
+ {#if has('align-center')}
555
+ <button
556
+ type="button"
557
+ onclick={() => editor.chain().focus().setTextAlign('center').run()}
558
+ data-tooltip="가운데 정렬"
559
+ aria-label="가운데 정렬"
560
+ class={cn(
561
+ "p-1.5 rounded-md transition-colors",
562
+ editor.isActive({ textAlign: 'center' })
563
+ ? "bg-primary/10 text-primary"
564
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
565
+ )}
566
+ >
567
+ <AlignCenter size={iconSize} />
568
+ </button>
569
+ {/if}
570
+ {#if has('align-right')}
571
+ <button
572
+ type="button"
573
+ onclick={() => editor.chain().focus().setTextAlign('right').run()}
574
+ data-tooltip="오른쪽 정렬"
575
+ aria-label="오른쪽 정렬"
576
+ class={cn(
577
+ "p-1.5 rounded-md transition-colors",
578
+ editor.isActive({ textAlign: 'right' })
579
+ ? "bg-primary/10 text-primary"
580
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
581
+ )}
582
+ >
583
+ <AlignRight size={iconSize} />
584
+ </button>
585
+ {/if}
586
+ </div>
587
+ {/if}
588
+
589
+ {#if hasInsertItems}
590
+ <!-- Insert + dropdown -->
591
+ <div class="hce-toolbar-group">
592
+ <div bind:this={insertMenuEl} class="relative">
593
+ <button
594
+ type="button"
595
+ onclick={() => (insertMenuOpen = !insertMenuOpen)}
596
+ data-tooltip="삽입"
597
+ aria-label="삽입"
598
+ class="flex items-center gap-1 px-2 py-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
599
+ >
600
+ <Plus size={iconSize} />
601
+ <span class="text-sm">삽입</span>
602
+ <ChevronDown size={12} />
603
+ </button>
604
+ {#if insertMenuOpen}
605
+ <div
606
+ class="absolute top-full left-0 mt-1 bg-popover border border-border rounded-lg shadow-lg z-50 py-1"
607
+ style="min-width: 220px"
608
+ >
609
+ {#if has('code-block')}
867
610
  <button
868
611
  type="button"
869
- disabled={!editor.can().splitCell()}
870
- class={cn(
871
- "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
872
- !editor.can().splitCell() && "opacity-30 pointer-events-none",
873
- )}
612
+ class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
874
613
  onclick={() =>
875
- runTableCommand(() =>
876
- editor.chain().focus().splitCell().run(),
877
- )}
614
+ runInsert(() => editor.chain().focus().setCodeBlock().run())}
878
615
  >
879
- <SplitSquareHorizontal size={14} /> 셀 분할
616
+ <Code2 size={14} /> 코드 블록
880
617
  </button>
618
+ {/if}
619
+ {#if has('pdf')}
881
620
  <button
882
621
  type="button"
883
- class="w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted"
884
- onclick={() => {
885
- tableMenuOpen = false;
886
- modalState = { type: "cellBg" };
887
- }}
622
+ class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
623
+ onclick={() => runInsert(onPdfClick)}
888
624
  >
889
- <Paintbrush size={14} /> 셀 배경색
625
+ <FileText size={14} /> PDF
890
626
  </button>
891
- <div class="h-px bg-border my-1"></div>
892
- <p
893
- class="px-3 py-1 text-xs font-semibold text-muted-foreground"
627
+ {/if}
628
+ {#if has('file') && onFileClick}
629
+ <button
630
+ type="button"
631
+ class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
632
+ onclick={() => runInsert(onFileClick!)}
894
633
  >
895
- 삭제
896
- </p>
634
+ <Paperclip size={14} /> 파일 첨부
635
+ </button>
636
+ {/if}
637
+ {#if has('table')}
897
638
  <button
898
639
  type="button"
899
- disabled={!editor.can().deleteRow()}
900
- class={cn(
901
- "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 text-destructive hover:bg-destructive/10",
902
- !editor.can().deleteRow() && "opacity-30 pointer-events-none",
903
- )}
640
+ class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
904
641
  onclick={() =>
905
- runTableCommand(() =>
906
- editor.chain().focus().deleteRow().run(),
642
+ runInsert(() =>
643
+ editor
644
+ .chain()
645
+ .focus()
646
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
647
+ .run(),
907
648
  )}
908
649
  >
909
- <Rows3 size={14} /> 행 삭제
650
+ <TableIcon size={14} /> 표 (3x3)
910
651
  </button>
652
+ {/if}
653
+
654
+ {#if has('horizontal-rule') || has('columns-2') || has('columns-3')}
655
+ <div class="h-px bg-border my-1"></div>
656
+ {/if}
657
+ {#if has('horizontal-rule')}
911
658
  <button
912
659
  type="button"
913
- disabled={!editor.can().deleteColumn()}
914
- class={cn(
915
- "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 text-destructive hover:bg-destructive/10",
916
- !editor.can().deleteColumn() && "opacity-30 pointer-events-none",
917
- )}
660
+ class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
918
661
  onclick={() =>
919
- runTableCommand(() =>
920
- editor.chain().focus().deleteColumn().run(),
662
+ runInsert(() =>
663
+ editor.chain().focus().setHorizontalRule().run(),
921
664
  )}
922
665
  >
923
- <Columns3 size={14} /> 열 삭제
666
+ <Minus size={14} /> 구분선
924
667
  </button>
668
+ {/if}
669
+ {#if has('columns-2')}
925
670
  <button
926
671
  type="button"
927
- disabled={!editor.can().deleteTable()}
928
- class={cn(
929
- "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 text-destructive hover:bg-destructive/10",
930
- !editor.can().deleteTable() && "opacity-30 pointer-events-none",
931
- )}
672
+ class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
932
673
  onclick={() =>
933
- runTableCommand(() =>
934
- editor.chain().focus().deleteTable().run(),
935
- )}
674
+ runInsert(() => editor.chain().focus().setColumns(2).run())}
936
675
  >
937
- <Trash2 size={14} /> 표 삭제
676
+ <Columns2 size={14} /> 2단 컬럼
938
677
  </button>
939
- {/if}
940
- </div>
941
- {/if}
942
- </div>
943
- {/if}
944
-
945
- <!-- Code block -->
946
- {#if has('code-block')}
947
- <button
948
- type="button"
949
- onclick={() => editor.chain().focus().setCodeBlock().run()}
950
- data-tooltip="코드 블록"
951
- aria-label="코드 블록"
952
- class={cn(
953
- "p-1.5 rounded-md transition-colors",
954
- editor.isActive("codeBlock")
955
- ? "bg-primary/10 text-primary"
956
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
957
- )}
958
- >
959
- <Code2 size={iconSize} />
960
- </button>
961
- {/if}
678
+ {/if}
679
+ {#if has('columns-3')}
680
+ <button
681
+ type="button"
682
+ class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
683
+ onclick={() =>
684
+ runInsert(() => editor.chain().focus().setColumns(3).run())}
685
+ >
686
+ <Columns3 size={14} /> 3단 컬럼
687
+ </button>
688
+ {/if}
962
689
 
690
+ {#if has('image') || has('link') || has('mbus')}
691
+ <div class="h-px bg-border my-1"></div>
692
+ {/if}
693
+ {#if has('image')}
694
+ <button
695
+ type="button"
696
+ class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
697
+ onclick={addImage}
698
+ >
699
+ <ImageIcon size={14} /> 이미지
700
+ </button>
701
+ {/if}
702
+ {#if has('link')}
703
+ <button
704
+ type="button"
705
+ class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
706
+ onclick={addLink}
707
+ >
708
+ <LinkIcon size={14} /> 링크
709
+ </button>
710
+ {/if}
711
+ {#if has('mbus')}
712
+ <button
713
+ type="button"
714
+ class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
715
+ onclick={addMbus}
716
+ >
717
+ <Tv size={14} /> 미디버스 영상
718
+ </button>
719
+ {/if}
720
+ </div>
721
+ {/if}
963
722
  </div>
964
- {/if}
965
-
966
- {#if has('undo') || has('redo')}
967
- <!-- Undo / Redo -->
968
- <div class="hce-toolbar-group">
969
-
970
- {#if has('undo')}
971
- <button
972
- type="button"
973
- onclick={() => editor.chain().focus().undo().run()}
974
- disabled={!editor.can().undo()}
975
- data-tooltip="실행 취소"
976
- aria-label="실행 취소"
977
- class={cn(
978
- "p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground",
979
- !editor.can().undo() && "opacity-30 pointer-events-none",
980
- )}
981
- >
982
- <Undo size={iconSize} />
983
- </button>
984
- {/if}
985
- {#if has('redo')}
986
- <button
987
- type="button"
988
- onclick={() => editor.chain().focus().redo().run()}
989
- disabled={!editor.can().redo()}
990
- data-tooltip="다시 실행"
991
- aria-label="다시 실행"
992
- class={cn(
993
- "p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground",
994
- !editor.can().redo() && "opacity-30 pointer-events-none",
995
- )}
996
- >
997
- <Redo size={iconSize} />
998
- </button>
999
- {/if}
1000
-
1001
723
  </div>
1002
724
  {/if}
1003
725
 
@@ -1041,47 +763,6 @@
1041
763
  onCancel={() => (modalState = null)}
1042
764
  />
1043
765
  {/if}
1044
- {#if modalState?.type === "cellBg"}
1045
- <!-- svelte-ignore a11y_click_events_have_key_events -->
1046
- <!-- svelte-ignore a11y_no_static_element_interactions -->
1047
- <div
1048
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
1049
- onclick={() => (modalState = null)}
1050
- >
1051
- <!-- svelte-ignore a11y_click_events_have_key_events -->
1052
- <!-- svelte-ignore a11y_no_static_element_interactions -->
1053
- <div
1054
- class="bg-popover border border-border rounded-xl shadow-lg p-4"
1055
- style="min-width: 240px"
1056
- onclick={(e) => e.stopPropagation()}
1057
- >
1058
- <p class="text-sm font-semibold mb-3">셀 배경색</p>
1059
- <div class="grid grid-cols-3 gap-2">
1060
- {#each CELL_COLORS as c}
1061
- <button
1062
- type="button"
1063
- title={c.label}
1064
- class="h-9 rounded-lg border border-border transition-transform hover:scale-105 flex items-center justify-center text-xs"
1065
- style="background: {c.value ||
1066
- '#fff'}; {!c.value
1067
- ? 'background-image: linear-gradient(135deg, transparent 45%, #ef4444 45%, #ef4444 55%, transparent 55%)'
1068
- : ''}"
1069
- onclick={() => {
1070
- editor
1071
- .chain()
1072
- .focus()
1073
- .setCellAttribute("backgroundColor", c.value || null)
1074
- .run();
1075
- modalState = null;
1076
- }}
1077
- >
1078
- {c.value ? "" : ""}
1079
- </button>
1080
- {/each}
1081
- </div>
1082
- </div>
1083
- </div>
1084
- {/if}
1085
766
  </div>
1086
767
 
1087
768
  <style>