@teriusu/rich-editor 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +178 -0
  2. package/dist/components/BubbleToolbar.svelte +299 -0
  3. package/dist/components/BubbleToolbar.svelte.d.ts +9 -0
  4. package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -0
  5. package/dist/components/FixedToolbar.svelte +892 -0
  6. package/dist/components/FixedToolbar.svelte.d.ts +11 -0
  7. package/dist/components/FixedToolbar.svelte.d.ts.map +1 -0
  8. package/dist/components/InputModal.svelte +102 -0
  9. package/dist/components/InputModal.svelte.d.ts +11 -0
  10. package/dist/components/InputModal.svelte.d.ts.map +1 -0
  11. package/dist/components/PdfViewer.svelte +152 -0
  12. package/dist/components/PdfViewer.svelte.d.ts +8 -0
  13. package/dist/components/PdfViewer.svelte.d.ts.map +1 -0
  14. package/dist/components/SlashCommandMenu.svelte +315 -0
  15. package/dist/components/SlashCommandMenu.svelte.d.ts +14 -0
  16. package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -0
  17. package/dist/components/TableBubbleMenu.svelte +472 -0
  18. package/dist/components/TableBubbleMenu.svelte.d.ts +8 -0
  19. package/dist/components/TableBubbleMenu.svelte.d.ts.map +1 -0
  20. package/dist/components/TipTapEditor.svelte +649 -0
  21. package/dist/components/TipTapEditor.svelte.d.ts +17 -0
  22. package/dist/components/TipTapEditor.svelte.d.ts.map +1 -0
  23. package/dist/extensions/Column.d.ts +3 -0
  24. package/dist/extensions/Column.d.ts.map +1 -0
  25. package/dist/extensions/Column.js +20 -0
  26. package/dist/extensions/Columns.d.ts +11 -0
  27. package/dist/extensions/Columns.d.ts.map +1 -0
  28. package/dist/extensions/Columns.js +103 -0
  29. package/dist/extensions/Columns.test.d.ts +2 -0
  30. package/dist/extensions/Columns.test.d.ts.map +1 -0
  31. package/dist/extensions/Columns.test.js +62 -0
  32. package/dist/extensions/FileAttachment.d.ts +24 -0
  33. package/dist/extensions/FileAttachment.d.ts.map +1 -0
  34. package/dist/extensions/FileAttachment.js +190 -0
  35. package/dist/extensions/FileAttachment.test.d.ts +2 -0
  36. package/dist/extensions/FileAttachment.test.d.ts.map +1 -0
  37. package/dist/extensions/FileAttachment.test.js +63 -0
  38. package/dist/extensions/FixedDetails.d.ts +2 -0
  39. package/dist/extensions/FixedDetails.d.ts.map +1 -0
  40. package/dist/extensions/FixedDetails.js +100 -0
  41. package/dist/extensions/FixedDetails.test.d.ts +2 -0
  42. package/dist/extensions/FixedDetails.test.d.ts.map +1 -0
  43. package/dist/extensions/FixedDetails.test.js +40 -0
  44. package/dist/extensions/Indent.d.ts +11 -0
  45. package/dist/extensions/Indent.d.ts.map +1 -0
  46. package/dist/extensions/Indent.js +87 -0
  47. package/dist/extensions/Indent.test.d.ts +2 -0
  48. package/dist/extensions/Indent.test.d.ts.map +1 -0
  49. package/dist/extensions/Indent.test.js +78 -0
  50. package/dist/extensions/PdfBlock.d.ts +3 -0
  51. package/dist/extensions/PdfBlock.d.ts.map +1 -0
  52. package/dist/extensions/PdfBlock.js +301 -0
  53. package/dist/extensions/PdfBlock.test.d.ts +2 -0
  54. package/dist/extensions/PdfBlock.test.d.ts.map +1 -0
  55. package/dist/extensions/PdfBlock.test.js +51 -0
  56. package/dist/extensions/VideoBlock.d.ts +16 -0
  57. package/dist/extensions/VideoBlock.d.ts.map +1 -0
  58. package/dist/extensions/VideoBlock.js +87 -0
  59. package/dist/extensions/VideoBlock.test.d.ts +2 -0
  60. package/dist/extensions/VideoBlock.test.d.ts.map +1 -0
  61. package/dist/extensions/VideoBlock.test.js +54 -0
  62. package/dist/index.d.ts +20 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +20 -0
  65. package/dist/styles/editor.css +698 -0
  66. package/dist/types.d.ts +44 -0
  67. package/dist/types.d.ts.map +1 -0
  68. package/dist/types.js +1 -0
  69. package/dist/utils/cn.d.ts +3 -0
  70. package/dist/utils/cn.d.ts.map +1 -0
  71. package/dist/utils/cn.js +5 -0
  72. package/dist/utils/cn.test.d.ts +2 -0
  73. package/dist/utils/cn.test.d.ts.map +1 -0
  74. package/dist/utils/cn.test.js +28 -0
  75. package/dist/utils/escape-html.d.ts +2 -0
  76. package/dist/utils/escape-html.d.ts.map +1 -0
  77. package/dist/utils/escape-html.js +7 -0
  78. package/dist/utils/escape-html.test.d.ts +2 -0
  79. package/dist/utils/escape-html.test.d.ts.map +1 -0
  80. package/dist/utils/escape-html.test.js +25 -0
  81. package/dist/utils/pdf.d.ts +9 -0
  82. package/dist/utils/pdf.d.ts.map +1 -0
  83. package/dist/utils/pdf.js +33 -0
  84. package/dist/utils/sanitize.d.ts +9 -0
  85. package/dist/utils/sanitize.d.ts.map +1 -0
  86. package/dist/utils/sanitize.js +115 -0
  87. package/dist/utils/sanitize.test.d.ts +2 -0
  88. package/dist/utils/sanitize.test.d.ts.map +1 -0
  89. package/dist/utils/sanitize.test.js +206 -0
  90. package/package.json +76 -0
@@ -0,0 +1,892 @@
1
+ <script lang="ts">
2
+ import type { Editor } from "@tiptap/core";
3
+ import {
4
+ Bold,
5
+ Italic,
6
+ Underline as UnderlineIcon,
7
+ Strikethrough,
8
+ Highlighter,
9
+ LinkIcon,
10
+ AlignLeft,
11
+ AlignCenter,
12
+ AlignRight,
13
+ Heading1,
14
+ Heading2,
15
+ Heading3,
16
+ List,
17
+ ListOrdered,
18
+ Quote,
19
+ Minus,
20
+ ImageIcon,
21
+ FileText,
22
+ Code2,
23
+ ChevronDown,
24
+ Undo,
25
+ Redo,
26
+ Table as TableIcon,
27
+ Rows3,
28
+ Columns3,
29
+ Trash2,
30
+ SplitSquareHorizontal,
31
+ Combine,
32
+ PanelTop,
33
+ PanelLeft,
34
+ Paintbrush,
35
+ CheckSquare,
36
+ Superscript,
37
+ Subscript,
38
+ Video,
39
+ ChevronRight,
40
+ Paperclip,
41
+ Film,
42
+ Columns2,
43
+ } from "lucide-svelte";
44
+ import { cn } from "../utils/cn";
45
+ import InputModal from "./InputModal.svelte";
46
+
47
+ let {
48
+ editor,
49
+ onPdfClick,
50
+ onFileClick,
51
+ onVideoClick,
52
+ }: {
53
+ editor: Editor;
54
+ onPdfClick: () => void;
55
+ onFileClick?: () => void;
56
+ onVideoClick?: () => void;
57
+ } = $props();
58
+
59
+ const iconSize = 16;
60
+
61
+ let codeMenuOpen = $state(false);
62
+ let tableMenuOpen = $state(false);
63
+ let modalState: { type: "link" | "image" | "cellBg" } | null = $state(null);
64
+ let codeMenuEl: HTMLDivElement | undefined = $state();
65
+ let tableMenuEl: HTMLDivElement | undefined = $state();
66
+
67
+ $effect(() => {
68
+ if (!codeMenuOpen && !tableMenuOpen) return;
69
+ function handleClick(e: MouseEvent) {
70
+ if (codeMenuEl && !codeMenuEl.contains(e.target as Node))
71
+ codeMenuOpen = false;
72
+ if (tableMenuEl && !tableMenuEl.contains(e.target as Node))
73
+ tableMenuOpen = false;
74
+ }
75
+ function handleKeyDown(e: KeyboardEvent) {
76
+ if (e.key === "Escape") {
77
+ codeMenuOpen = false;
78
+ tableMenuOpen = false;
79
+ }
80
+ }
81
+ document.addEventListener("mousedown", handleClick);
82
+ document.addEventListener("keydown", handleKeyDown);
83
+ return () => {
84
+ document.removeEventListener("mousedown", handleClick);
85
+ document.removeEventListener("keydown", handleKeyDown);
86
+ };
87
+ });
88
+
89
+ function addLink() {
90
+ modalState = { type: "link" };
91
+ }
92
+
93
+ function addImage() {
94
+ modalState = { type: "image" };
95
+ }
96
+
97
+ function runTableCommand(command: () => boolean) {
98
+ command();
99
+ tableMenuOpen = false;
100
+ }
101
+
102
+ const isInTable = $derived(editor.isActive("table"));
103
+
104
+ const CELL_COLORS = [
105
+ { label: "없음", value: "" },
106
+ { label: "밝은 회색", value: "#f1f5f9" },
107
+ { label: "밝은 파랑", value: "#dbeafe" },
108
+ { label: "밝은 초록", value: "#dcfce7" },
109
+ { label: "밝은 노랑", value: "#fef9c3" },
110
+ { label: "밝은 주황", value: "#ffedd5" },
111
+ { label: "밝은 빨강", value: "#fee2e2" },
112
+ { label: "밝은 보라", value: "#ede9fe" },
113
+ { label: "밝은 분홍", value: "#fce7f3" },
114
+ ];
115
+ </script>
116
+
117
+ <div
118
+ class="sticky z-30 flex flex-wrap items-center gap-0.5 px-3 py-2 border-b border-border bg-background rounded-t-xl"
119
+ style="top: var(--header-height, 74px)"
120
+ >
121
+ <!-- Format -->
122
+ <button
123
+ type="button"
124
+ onclick={() => editor.chain().focus().toggleBold().run()}
125
+ data-tooltip="굵게"
126
+ aria-label="굵게"
127
+ class={cn(
128
+ "p-1.5 rounded-md transition-colors",
129
+ editor.isActive("bold")
130
+ ? "bg-primary/10 text-primary"
131
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
132
+ )}
133
+ >
134
+ <Bold size={iconSize} />
135
+ </button>
136
+ <button
137
+ type="button"
138
+ onclick={() => editor.chain().focus().toggleItalic().run()}
139
+ data-tooltip="기울임"
140
+ aria-label="기울임"
141
+ class={cn(
142
+ "p-1.5 rounded-md transition-colors",
143
+ editor.isActive("italic")
144
+ ? "bg-primary/10 text-primary"
145
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
146
+ )}
147
+ >
148
+ <Italic size={iconSize} />
149
+ </button>
150
+ <button
151
+ type="button"
152
+ onclick={() => editor.chain().focus().toggleUnderline().run()}
153
+ data-tooltip="밑줄"
154
+ aria-label="밑줄"
155
+ class={cn(
156
+ "p-1.5 rounded-md transition-colors",
157
+ editor.isActive("underline")
158
+ ? "bg-primary/10 text-primary"
159
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
160
+ )}
161
+ >
162
+ <UnderlineIcon size={iconSize} />
163
+ </button>
164
+ <button
165
+ type="button"
166
+ onclick={() => editor.chain().focus().toggleStrike().run()}
167
+ data-tooltip="취소선"
168
+ aria-label="취소선"
169
+ class={cn(
170
+ "p-1.5 rounded-md transition-colors",
171
+ editor.isActive("strike")
172
+ ? "bg-primary/10 text-primary"
173
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
174
+ )}
175
+ >
176
+ <Strikethrough size={iconSize} />
177
+ </button>
178
+ <button
179
+ type="button"
180
+ onclick={() => editor.chain().focus().toggleHighlight().run()}
181
+ data-tooltip="하이라이트"
182
+ aria-label="하이라이트"
183
+ class={cn(
184
+ "p-1.5 rounded-md transition-colors",
185
+ editor.isActive("highlight")
186
+ ? "bg-primary/10 text-primary"
187
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
188
+ )}
189
+ >
190
+ <Highlighter size={iconSize} />
191
+ </button>
192
+ <button
193
+ type="button"
194
+ onclick={() => editor.chain().focus().toggleSuperscript().run()}
195
+ data-tooltip="위첨자"
196
+ aria-label="위첨자"
197
+ class={cn(
198
+ "p-1.5 rounded-md transition-colors",
199
+ editor.isActive("superscript")
200
+ ? "bg-primary/10 text-primary"
201
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
202
+ )}
203
+ >
204
+ <Superscript size={iconSize} />
205
+ </button>
206
+ <button
207
+ type="button"
208
+ onclick={() => editor.chain().focus().toggleSubscript().run()}
209
+ data-tooltip="아래첨자"
210
+ aria-label="아래첨자"
211
+ class={cn(
212
+ "p-1.5 rounded-md transition-colors",
213
+ editor.isActive("subscript")
214
+ ? "bg-primary/10 text-primary"
215
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
216
+ )}
217
+ >
218
+ <Subscript size={iconSize} />
219
+ </button>
220
+
221
+ <div class="w-px h-6 bg-border mx-0.5"></div>
222
+
223
+ <!-- Alignment -->
224
+ <button
225
+ type="button"
226
+ onclick={() => editor.chain().focus().setTextAlign("left").run()}
227
+ data-tooltip="왼쪽 정렬"
228
+ aria-label="왼쪽 정렬"
229
+ class={cn(
230
+ "p-1.5 rounded-md transition-colors",
231
+ editor.isActive({ textAlign: "left" })
232
+ ? "bg-primary/10 text-primary"
233
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
234
+ )}
235
+ >
236
+ <AlignLeft size={iconSize} />
237
+ </button>
238
+ <button
239
+ type="button"
240
+ onclick={() => editor.chain().focus().setTextAlign("center").run()}
241
+ data-tooltip="가운데 정렬"
242
+ aria-label="가운데 정렬"
243
+ class={cn(
244
+ "p-1.5 rounded-md transition-colors",
245
+ editor.isActive({ textAlign: "center" })
246
+ ? "bg-primary/10 text-primary"
247
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
248
+ )}
249
+ >
250
+ <AlignCenter size={iconSize} />
251
+ </button>
252
+ <button
253
+ type="button"
254
+ onclick={() => editor.chain().focus().setTextAlign("right").run()}
255
+ data-tooltip="오른쪽 정렬"
256
+ aria-label="오른쪽 정렬"
257
+ class={cn(
258
+ "p-1.5 rounded-md transition-colors",
259
+ editor.isActive({ textAlign: "right" })
260
+ ? "bg-primary/10 text-primary"
261
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
262
+ )}
263
+ >
264
+ <AlignRight size={iconSize} />
265
+ </button>
266
+
267
+ <div class="w-px h-6 bg-border mx-0.5"></div>
268
+
269
+ <!-- Headings -->
270
+ <button
271
+ type="button"
272
+ onclick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
273
+ data-tooltip="제목 1"
274
+ aria-label="제목 1"
275
+ class={cn(
276
+ "p-1.5 rounded-md transition-colors",
277
+ editor.isActive("heading", { level: 1 })
278
+ ? "bg-primary/10 text-primary"
279
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
280
+ )}
281
+ >
282
+ <Heading1 size={iconSize} />
283
+ </button>
284
+ <button
285
+ type="button"
286
+ onclick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
287
+ data-tooltip="제목 2"
288
+ aria-label="제목 2"
289
+ class={cn(
290
+ "p-1.5 rounded-md transition-colors",
291
+ editor.isActive("heading", { level: 2 })
292
+ ? "bg-primary/10 text-primary"
293
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
294
+ )}
295
+ >
296
+ <Heading2 size={iconSize} />
297
+ </button>
298
+ <button
299
+ type="button"
300
+ onclick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
301
+ data-tooltip="제목 3"
302
+ aria-label="제목 3"
303
+ class={cn(
304
+ "p-1.5 rounded-md transition-colors",
305
+ editor.isActive("heading", { level: 3 })
306
+ ? "bg-primary/10 text-primary"
307
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
308
+ )}
309
+ >
310
+ <Heading3 size={iconSize} />
311
+ </button>
312
+
313
+ <div class="w-px h-6 bg-border mx-0.5"></div>
314
+
315
+ <!-- Lists -->
316
+ <button
317
+ type="button"
318
+ onclick={() => editor.chain().focus().toggleBulletList().run()}
319
+ data-tooltip="글머리 목록"
320
+ aria-label="글머리 목록"
321
+ class={cn(
322
+ "p-1.5 rounded-md transition-colors",
323
+ editor.isActive("bulletList")
324
+ ? "bg-primary/10 text-primary"
325
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
326
+ )}
327
+ >
328
+ <List size={iconSize} />
329
+ </button>
330
+ <button
331
+ type="button"
332
+ onclick={() => editor.chain().focus().toggleOrderedList().run()}
333
+ data-tooltip="번호 목록"
334
+ aria-label="번호 목록"
335
+ class={cn(
336
+ "p-1.5 rounded-md transition-colors",
337
+ editor.isActive("orderedList")
338
+ ? "bg-primary/10 text-primary"
339
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
340
+ )}
341
+ >
342
+ <ListOrdered size={iconSize} />
343
+ </button>
344
+ <button
345
+ type="button"
346
+ onclick={() => editor.chain().focus().toggleTaskList().run()}
347
+ data-tooltip="체크리스트"
348
+ aria-label="체크리스트"
349
+ class={cn(
350
+ "p-1.5 rounded-md transition-colors",
351
+ editor.isActive("taskList")
352
+ ? "bg-primary/10 text-primary"
353
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
354
+ )}
355
+ >
356
+ <CheckSquare size={iconSize} />
357
+ </button>
358
+ <button
359
+ type="button"
360
+ onclick={() => editor.chain().focus().toggleBlockquote().run()}
361
+ data-tooltip="인용문"
362
+ aria-label="인용문"
363
+ class={cn(
364
+ "p-1.5 rounded-md transition-colors",
365
+ editor.isActive("blockquote")
366
+ ? "bg-primary/10 text-primary"
367
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
368
+ )}
369
+ >
370
+ <Quote size={iconSize} />
371
+ </button>
372
+ <button
373
+ type="button"
374
+ onclick={() => editor.chain().focus().setHorizontalRule().run()}
375
+ data-tooltip="구분선"
376
+ aria-label="구분선"
377
+ class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
378
+ >
379
+ <Minus size={iconSize} />
380
+ </button>
381
+ <button
382
+ type="button"
383
+ onclick={() => editor.chain().focus().setDetails().run()}
384
+ data-tooltip="토글 (접기/펼치기)"
385
+ aria-label="토글 (접기/펼치기)"
386
+ class={cn(
387
+ "p-1.5 rounded-md transition-colors",
388
+ editor.isActive("details")
389
+ ? "bg-primary/10 text-primary"
390
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
391
+ )}
392
+ >
393
+ <ChevronRight size={iconSize} />
394
+ </button>
395
+
396
+ <div class="w-px h-6 bg-border mx-0.5"></div>
397
+
398
+ <!-- Media -->
399
+ <button
400
+ type="button"
401
+ onclick={addLink}
402
+ data-tooltip="링크"
403
+ aria-label="링크"
404
+ class={cn(
405
+ "p-1.5 rounded-md transition-colors",
406
+ editor.isActive("link")
407
+ ? "bg-primary/10 text-primary"
408
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
409
+ )}
410
+ >
411
+ <LinkIcon size={iconSize} />
412
+ </button>
413
+ <button
414
+ type="button"
415
+ onclick={addImage}
416
+ data-tooltip="이미지"
417
+ aria-label="이미지"
418
+ class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
419
+ >
420
+ <ImageIcon size={iconSize} />
421
+ </button>
422
+ <button
423
+ type="button"
424
+ onclick={onPdfClick}
425
+ data-tooltip="PDF 삽입"
426
+ aria-label="PDF 삽입"
427
+ class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
428
+ >
429
+ <FileText size={iconSize} />
430
+ </button>
431
+ <button
432
+ type="button"
433
+ onclick={() => {
434
+ const url = window.prompt("YouTube URL을 입력하세요");
435
+ if (url) editor.chain().focus().setYoutubeVideo({ src: url }).run();
436
+ }}
437
+ data-tooltip="YouTube 영상"
438
+ aria-label="YouTube 영상"
439
+ class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
440
+ >
441
+ <Video size={iconSize} />
442
+ </button>
443
+ {#if onVideoClick}
444
+ <button
445
+ type="button"
446
+ onclick={onVideoClick}
447
+ data-tooltip="영상 파일"
448
+ aria-label="영상 파일"
449
+ class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
450
+ >
451
+ <Film size={iconSize} />
452
+ </button>
453
+ {/if}
454
+ {#if onFileClick}
455
+ <button
456
+ type="button"
457
+ onclick={onFileClick}
458
+ data-tooltip="파일 첨부"
459
+ aria-label="파일 첨부"
460
+ class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
461
+ >
462
+ <Paperclip size={iconSize} />
463
+ </button>
464
+ {/if}
465
+
466
+ <!-- Columns -->
467
+ <button
468
+ type="button"
469
+ onclick={() => editor.chain().focus().setColumns(2).run()}
470
+ data-tooltip="2단 컬럼"
471
+ aria-label="2단 컬럼"
472
+ class={cn(
473
+ "p-1.5 rounded-md transition-colors",
474
+ editor.isActive("columns")
475
+ ? "bg-primary/10 text-primary"
476
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
477
+ )}
478
+ >
479
+ <Columns2 size={iconSize} />
480
+ </button>
481
+ <button
482
+ type="button"
483
+ onclick={() => editor.chain().focus().setColumns(3).run()}
484
+ data-tooltip="3단 컬럼"
485
+ aria-label="3단 컬럼"
486
+ class="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
487
+ >
488
+ <Columns3 size={iconSize} />
489
+ </button>
490
+
491
+ <!-- Table menu -->
492
+ <div bind:this={tableMenuEl} class="relative">
493
+ <button
494
+ type="button"
495
+ onclick={() => (tableMenuOpen = !tableMenuOpen)}
496
+ data-tooltip="표"
497
+ aria-label="표"
498
+ class={cn(
499
+ "flex items-center gap-0.5 p-1.5 rounded-md transition-colors",
500
+ isInTable
501
+ ? "bg-primary/10 text-primary"
502
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
503
+ )}
504
+ >
505
+ <TableIcon size={iconSize} />
506
+ <ChevronDown size={12} />
507
+ </button>
508
+ {#if tableMenuOpen}
509
+ <div
510
+ class="absolute top-full left-0 mt-1 bg-popover border border-border rounded-lg shadow-lg z-50 py-1"
511
+ style="min-width: 220px"
512
+ >
513
+ {#if !isInTable}
514
+ <button
515
+ type="button"
516
+ class="w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted"
517
+ onclick={() =>
518
+ runTableCommand(() =>
519
+ editor
520
+ .chain()
521
+ .focus()
522
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
523
+ .run(),
524
+ )}
525
+ >
526
+ <TableIcon size={14} />
527
+ 표 삽입 (3x3)
528
+ </button>
529
+ <button
530
+ type="button"
531
+ class="w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted"
532
+ onclick={() =>
533
+ runTableCommand(() =>
534
+ editor
535
+ .chain()
536
+ .focus()
537
+ .insertTable({ rows: 4, cols: 4, withHeaderRow: true })
538
+ .run(),
539
+ )}
540
+ >
541
+ <PanelTop size={14} />
542
+ 헤더 포함 4x4
543
+ </button>
544
+ {:else}
545
+ <p
546
+ class="px-3 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"
547
+ >
548
+ 구조
549
+ </p>
550
+ <button
551
+ type="button"
552
+ disabled={!editor.can().addRowBefore()}
553
+ class={cn(
554
+ "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
555
+ !editor.can().addRowBefore() && "opacity-30 pointer-events-none",
556
+ )}
557
+ onclick={() =>
558
+ runTableCommand(() =>
559
+ editor.chain().focus().addRowBefore().run(),
560
+ )}
561
+ >
562
+ <Rows3 size={14} /> 행 위에 추가
563
+ </button>
564
+ <button
565
+ type="button"
566
+ disabled={!editor.can().addRowAfter()}
567
+ class={cn(
568
+ "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
569
+ !editor.can().addRowAfter() && "opacity-30 pointer-events-none",
570
+ )}
571
+ onclick={() =>
572
+ runTableCommand(() =>
573
+ editor.chain().focus().addRowAfter().run(),
574
+ )}
575
+ >
576
+ <Rows3 size={14} /> 행 아래에 추가
577
+ </button>
578
+ <button
579
+ type="button"
580
+ disabled={!editor.can().addColumnBefore()}
581
+ class={cn(
582
+ "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
583
+ !editor.can().addColumnBefore() &&
584
+ "opacity-30 pointer-events-none",
585
+ )}
586
+ onclick={() =>
587
+ runTableCommand(() =>
588
+ editor.chain().focus().addColumnBefore().run(),
589
+ )}
590
+ >
591
+ <Columns3 size={14} /> 열 왼쪽에 추가
592
+ </button>
593
+ <button
594
+ type="button"
595
+ disabled={!editor.can().addColumnAfter()}
596
+ class={cn(
597
+ "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
598
+ !editor.can().addColumnAfter() &&
599
+ "opacity-30 pointer-events-none",
600
+ )}
601
+ onclick={() =>
602
+ runTableCommand(() =>
603
+ editor.chain().focus().addColumnAfter().run(),
604
+ )}
605
+ >
606
+ <Columns3 size={14} /> 열 오른쪽에 추가
607
+ </button>
608
+ <div class="h-px bg-border my-1"></div>
609
+ <p
610
+ class="px-3 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"
611
+ >
612
+ 헤더 / 셀
613
+ </p>
614
+ <button
615
+ type="button"
616
+ disabled={!editor.can().toggleHeaderRow()}
617
+ class={cn(
618
+ "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
619
+ !editor.can().toggleHeaderRow() &&
620
+ "opacity-30 pointer-events-none",
621
+ )}
622
+ onclick={() =>
623
+ runTableCommand(() =>
624
+ editor.chain().focus().toggleHeaderRow().run(),
625
+ )}
626
+ >
627
+ <PanelTop size={14} /> 헤더 행 토글
628
+ </button>
629
+ <button
630
+ type="button"
631
+ disabled={!editor.can().toggleHeaderColumn()}
632
+ class={cn(
633
+ "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
634
+ !editor.can().toggleHeaderColumn() &&
635
+ "opacity-30 pointer-events-none",
636
+ )}
637
+ onclick={() =>
638
+ runTableCommand(() =>
639
+ editor.chain().focus().toggleHeaderColumn().run(),
640
+ )}
641
+ >
642
+ <PanelLeft size={14} /> 헤더 열 토글
643
+ </button>
644
+ <button
645
+ type="button"
646
+ disabled={!editor.can().mergeCells()}
647
+ class={cn(
648
+ "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
649
+ !editor.can().mergeCells() && "opacity-30 pointer-events-none",
650
+ )}
651
+ onclick={() =>
652
+ runTableCommand(() =>
653
+ editor.chain().focus().mergeCells().run(),
654
+ )}
655
+ >
656
+ <Combine size={14} /> 셀 병합
657
+ </button>
658
+ <button
659
+ type="button"
660
+ disabled={!editor.can().splitCell()}
661
+ class={cn(
662
+ "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted",
663
+ !editor.can().splitCell() && "opacity-30 pointer-events-none",
664
+ )}
665
+ onclick={() =>
666
+ runTableCommand(() =>
667
+ editor.chain().focus().splitCell().run(),
668
+ )}
669
+ >
670
+ <SplitSquareHorizontal size={14} /> 셀 분할
671
+ </button>
672
+ <button
673
+ type="button"
674
+ class="w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 hover:bg-muted"
675
+ onclick={() => {
676
+ tableMenuOpen = false;
677
+ modalState = { type: "cellBg" };
678
+ }}
679
+ >
680
+ <Paintbrush size={14} /> 셀 배경색
681
+ </button>
682
+ <div class="h-px bg-border my-1"></div>
683
+ <p
684
+ class="px-3 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"
685
+ >
686
+ 삭제
687
+ </p>
688
+ <button
689
+ type="button"
690
+ disabled={!editor.can().deleteRow()}
691
+ class={cn(
692
+ "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 text-destructive hover:bg-destructive/10",
693
+ !editor.can().deleteRow() && "opacity-30 pointer-events-none",
694
+ )}
695
+ onclick={() =>
696
+ runTableCommand(() =>
697
+ editor.chain().focus().deleteRow().run(),
698
+ )}
699
+ >
700
+ <Rows3 size={14} /> 행 삭제
701
+ </button>
702
+ <button
703
+ type="button"
704
+ disabled={!editor.can().deleteColumn()}
705
+ class={cn(
706
+ "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 text-destructive hover:bg-destructive/10",
707
+ !editor.can().deleteColumn() && "opacity-30 pointer-events-none",
708
+ )}
709
+ onclick={() =>
710
+ runTableCommand(() =>
711
+ editor.chain().focus().deleteColumn().run(),
712
+ )}
713
+ >
714
+ <Columns3 size={14} /> 열 삭제
715
+ </button>
716
+ <button
717
+ type="button"
718
+ disabled={!editor.can().deleteTable()}
719
+ class={cn(
720
+ "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 text-destructive hover:bg-destructive/10",
721
+ !editor.can().deleteTable() && "opacity-30 pointer-events-none",
722
+ )}
723
+ onclick={() =>
724
+ runTableCommand(() =>
725
+ editor.chain().focus().deleteTable().run(),
726
+ )}
727
+ >
728
+ <Trash2 size={14} /> 표 삭제
729
+ </button>
730
+ {/if}
731
+ </div>
732
+ {/if}
733
+ </div>
734
+
735
+ <!-- Code block menu -->
736
+ <div bind:this={codeMenuEl} class="relative">
737
+ <button
738
+ type="button"
739
+ onclick={() => (codeMenuOpen = !codeMenuOpen)}
740
+ data-tooltip="코드 블록"
741
+ aria-label="코드 블록"
742
+ class={cn(
743
+ "flex items-center gap-0.5 p-1.5 rounded-md transition-colors",
744
+ editor.isActive("codeBlock")
745
+ ? "bg-primary/10 text-primary"
746
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
747
+ )}
748
+ >
749
+ <Code2 size={iconSize} />
750
+ <ChevronDown size={12} />
751
+ </button>
752
+ {#if codeMenuOpen}
753
+ <div
754
+ class="absolute top-full left-0 mt-1 bg-popover border border-border rounded-lg shadow-lg z-50 py-1"
755
+ style="min-width: 130px"
756
+ >
757
+ <button
758
+ type="button"
759
+ class="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors"
760
+ onclick={() => {
761
+ editor.chain().focus().setCodeBlock({ language: "cpp" }).run();
762
+ codeMenuOpen = false;
763
+ }}
764
+ >
765
+ C++
766
+ </button>
767
+ <button
768
+ type="button"
769
+ class="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors"
770
+ onclick={() => {
771
+ editor.chain().focus().setCodeBlock({ language: "python" }).run();
772
+ codeMenuOpen = false;
773
+ }}
774
+ >
775
+ Python
776
+ </button>
777
+ <div class="h-px bg-border my-1"></div>
778
+ <button
779
+ type="button"
780
+ class="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors"
781
+ onclick={() => {
782
+ editor.chain().focus().setCodeBlock({ language: "" }).run();
783
+ codeMenuOpen = false;
784
+ }}
785
+ >
786
+ 일반 코드
787
+ </button>
788
+ </div>
789
+ {/if}
790
+ </div>
791
+
792
+ <div class="w-px h-6 bg-border mx-0.5"></div>
793
+
794
+ <!-- Undo / Redo -->
795
+ <button
796
+ type="button"
797
+ onclick={() => editor.chain().focus().undo().run()}
798
+ disabled={!editor.can().undo()}
799
+ data-tooltip="실행 취소"
800
+ aria-label="실행 취소"
801
+ class={cn(
802
+ "p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground",
803
+ !editor.can().undo() && "opacity-30 pointer-events-none",
804
+ )}
805
+ >
806
+ <Undo size={iconSize} />
807
+ </button>
808
+ <button
809
+ type="button"
810
+ onclick={() => editor.chain().focus().redo().run()}
811
+ disabled={!editor.can().redo()}
812
+ data-tooltip="다시 실행"
813
+ aria-label="다시 실행"
814
+ class={cn(
815
+ "p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground",
816
+ !editor.can().redo() && "opacity-30 pointer-events-none",
817
+ )}
818
+ >
819
+ <Redo size={iconSize} />
820
+ </button>
821
+
822
+ <!-- Modals -->
823
+ {#if modalState?.type === "link"}
824
+ <InputModal
825
+ data-tooltip="링크 URL 입력"
826
+ placeholder="https://example.com"
827
+ defaultValue={editor.isActive("link") ? editor.getAttributes("link").href || "" : ""}
828
+ onConfirm={(url) => {
829
+ editor
830
+ .chain()
831
+ .focus()
832
+ .extendMarkRange("link")
833
+ .setLink({ href: url })
834
+ .run();
835
+ modalState = null;
836
+ }}
837
+ onCancel={() => (modalState = null)}
838
+ />
839
+ {/if}
840
+ {#if modalState?.type === "image"}
841
+ <InputModal
842
+ data-tooltip="이미지 URL 입력"
843
+ placeholder="https://example.com/image.png"
844
+ onConfirm={(url) => {
845
+ editor.chain().focus().setImage({ src: url }).run();
846
+ modalState = null;
847
+ }}
848
+ onCancel={() => (modalState = null)}
849
+ />
850
+ {/if}
851
+ {#if modalState?.type === "cellBg"}
852
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
853
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
854
+ <div
855
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
856
+ onclick={() => (modalState = null)}
857
+ >
858
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
859
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
860
+ <div
861
+ class="bg-popover border border-border rounded-xl shadow-lg p-4"
862
+ style="min-width: 240px"
863
+ onclick={(e) => e.stopPropagation()}
864
+ >
865
+ <p class="text-sm font-semibold mb-3">셀 배경색</p>
866
+ <div class="grid grid-cols-3 gap-2">
867
+ {#each CELL_COLORS as c}
868
+ <button
869
+ type="button"
870
+ title={c.label}
871
+ class="h-9 rounded-lg border border-border transition-transform hover:scale-105 flex items-center justify-center text-xs"
872
+ style="background: {c.value ||
873
+ '#fff'}; {!c.value
874
+ ? 'background-image: linear-gradient(135deg, transparent 45%, #ef4444 45%, #ef4444 55%, transparent 55%)'
875
+ : ''}"
876
+ onclick={() => {
877
+ editor
878
+ .chain()
879
+ .focus()
880
+ .setCellAttribute("backgroundColor", c.value || null)
881
+ .run();
882
+ modalState = null;
883
+ }}
884
+ >
885
+ {c.value ? "" : ""}
886
+ </button>
887
+ {/each}
888
+ </div>
889
+ </div>
890
+ </div>
891
+ {/if}
892
+ </div>