podo-ui 0.9.7 → 1.0.2

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 (74) hide show
  1. package/cdn/podo-datepicker.css +1 -1
  2. package/cdn/podo-datepicker.js +1 -1
  3. package/cdn/podo-datepicker.min.css +1 -1
  4. package/cdn/podo-datepicker.min.js +1 -1
  5. package/cdn/podo-ui.css +4 -1
  6. package/cdn/podo-ui.min.css +1 -1
  7. package/dist/react/atom/editor.d.ts.map +1 -1
  8. package/dist/react/atom/editor.js +94 -2
  9. package/dist/svelte/actions/portal.d.ts +18 -0
  10. package/dist/svelte/actions/portal.js +42 -0
  11. package/dist/svelte/atom/Avatar.svelte +97 -0
  12. package/dist/svelte/atom/Avatar.svelte.d.ts +31 -0
  13. package/dist/svelte/atom/Button.svelte +86 -0
  14. package/dist/svelte/atom/Button.svelte.d.ts +26 -0
  15. package/dist/svelte/atom/Checkbox.svelte +56 -0
  16. package/dist/svelte/atom/Checkbox.svelte.d.ts +16 -0
  17. package/dist/svelte/atom/Chip.svelte +60 -0
  18. package/dist/svelte/atom/Chip.svelte.d.ts +25 -0
  19. package/dist/svelte/atom/Editor.svelte +1314 -0
  20. package/dist/svelte/atom/Editor.svelte.d.ts +30 -0
  21. package/dist/svelte/atom/EditorView.svelte +16 -0
  22. package/dist/svelte/atom/EditorView.svelte.d.ts +9 -0
  23. package/dist/svelte/atom/File.svelte +33 -0
  24. package/dist/svelte/atom/File.svelte.d.ts +14 -0
  25. package/dist/svelte/atom/Input.svelte +80 -0
  26. package/dist/svelte/atom/Input.svelte.d.ts +19 -0
  27. package/dist/svelte/atom/Label.svelte +43 -0
  28. package/dist/svelte/atom/Label.svelte.d.ts +19 -0
  29. package/dist/svelte/atom/Radio.svelte +69 -0
  30. package/dist/svelte/atom/Radio.svelte.d.ts +26 -0
  31. package/dist/svelte/atom/RadioGroup.svelte +46 -0
  32. package/dist/svelte/atom/RadioGroup.svelte.d.ts +16 -0
  33. package/dist/svelte/atom/Select.svelte +65 -0
  34. package/dist/svelte/atom/Select.svelte.d.ts +26 -0
  35. package/dist/svelte/atom/Textarea.svelte +53 -0
  36. package/dist/svelte/atom/Textarea.svelte.d.ts +13 -0
  37. package/dist/svelte/atom/Toggle.svelte +48 -0
  38. package/dist/svelte/atom/Toggle.svelte.d.ts +14 -0
  39. package/dist/svelte/atom/Tooltip.svelte +78 -0
  40. package/dist/svelte/atom/Tooltip.svelte.d.ts +23 -0
  41. package/dist/svelte/atom/avatar.module.scss +82 -0
  42. package/dist/svelte/atom/editor-view.module.scss +251 -0
  43. package/dist/svelte/atom/input.module.scss +98 -0
  44. package/dist/svelte/atom/textarea.module.scss +17 -0
  45. package/dist/svelte/atom/tooltip.module.scss +227 -0
  46. package/dist/svelte/index.d.ts +26 -0
  47. package/dist/svelte/index.js +30 -0
  48. package/dist/svelte/molecule/DatePicker.svelte +986 -0
  49. package/dist/svelte/molecule/DatePicker.svelte.d.ts +71 -0
  50. package/dist/svelte/molecule/Field.svelte +81 -0
  51. package/dist/svelte/molecule/Field.svelte.d.ts +26 -0
  52. package/dist/svelte/molecule/Pagination.svelte +95 -0
  53. package/dist/svelte/molecule/Pagination.svelte.d.ts +14 -0
  54. package/dist/svelte/molecule/Tab.svelte +69 -0
  55. package/dist/svelte/molecule/Tab.svelte.d.ts +26 -0
  56. package/dist/svelte/molecule/TabPanel.svelte +24 -0
  57. package/dist/svelte/molecule/TabPanel.svelte.d.ts +14 -0
  58. package/dist/svelte/molecule/Table.svelte +109 -0
  59. package/dist/svelte/molecule/Table.svelte.d.ts +54 -0
  60. package/dist/svelte/molecule/Toast.svelte +111 -0
  61. package/dist/svelte/molecule/Toast.svelte.d.ts +25 -0
  62. package/dist/svelte/molecule/ToastProvider.svelte +74 -0
  63. package/dist/svelte/molecule/ToastProvider.svelte.d.ts +8 -0
  64. package/dist/svelte/molecule/field.module.scss +22 -0
  65. package/dist/svelte/molecule/pagination.module.scss +61 -0
  66. package/dist/svelte/molecule/toast-container.module.scss +70 -0
  67. package/dist/svelte/molecule/toast.module.scss +12 -0
  68. package/dist/svelte/stores/toast.d.ts +45 -0
  69. package/dist/svelte/stores/toast.js +55 -0
  70. package/dist/svelte/stores/validation.d.ts +15 -0
  71. package/dist/svelte/stores/validation.js +38 -0
  72. package/global.scss +1 -0
  73. package/package.json +32 -5
  74. package/vite-fonts.scss +1 -1
@@ -0,0 +1,1314 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import type { z } from 'zod';
4
+ import { createValidation } from '../stores/validation';
5
+ import styles from '../../react/atom/editor.module.scss';
6
+
7
+ export type ToolbarItem =
8
+ | 'undo-redo'
9
+ | 'paragraph'
10
+ | 'text-style'
11
+ | 'color'
12
+ | 'align'
13
+ | 'list'
14
+ | 'table'
15
+ | 'link'
16
+ | 'image'
17
+ | 'youtube'
18
+ | 'hr'
19
+ | 'format'
20
+ | 'code';
21
+
22
+ interface Props {
23
+ /** HTML content */
24
+ value?: string;
25
+ /** Editor width */
26
+ width?: string;
27
+ /** Editor height */
28
+ height?: string | 'contents';
29
+ /** Minimum height */
30
+ minHeight?: string;
31
+ /** Maximum height */
32
+ maxHeight?: string;
33
+ /** Allow resizing */
34
+ resizable?: boolean;
35
+ /** Change handler */
36
+ onchange?: (content: string) => void;
37
+ /** Zod validator */
38
+ validator?: z.ZodType<unknown>;
39
+ /** Placeholder text */
40
+ placeholder?: string;
41
+ /** Toolbar items to show */
42
+ toolbar?: ToolbarItem[];
43
+ /** Additional class name */
44
+ class?: string;
45
+ }
46
+
47
+ let {
48
+ value = $bindable(''),
49
+ width = '100%',
50
+ height = '400px',
51
+ minHeight,
52
+ maxHeight,
53
+ resizable = false,
54
+ onchange,
55
+ validator,
56
+ placeholder = '내용을 입력하세요...',
57
+ toolbar,
58
+ class: className = '',
59
+ ...rest
60
+ }: Props & Record<string, unknown> = $props();
61
+
62
+ const { message, statusClass, validate } = createValidation(validator);
63
+
64
+ // State
65
+ let currentParagraphStyle = $state('p');
66
+ let isParagraphDropdownOpen = $state(false);
67
+ let isTextColorOpen = $state(false);
68
+ let isBgColorOpen = $state(false);
69
+ let isAlignDropdownOpen = $state(false);
70
+ let currentAlign = $state('left');
71
+ let isLinkDropdownOpen = $state(false);
72
+ let linkUrl = $state('');
73
+ let linkTarget = $state('_blank');
74
+ let isEditLinkPopupOpen = $state(false);
75
+ let selectedLinkElement = $state<HTMLAnchorElement | null>(null);
76
+ let editLinkUrl = $state('');
77
+ let editLinkTarget = $state('_self');
78
+ let savedSelection = $state<Range | null>(null);
79
+ let isImageDropdownOpen = $state(false);
80
+ let imageTabMode = $state<'file' | 'url'>('file');
81
+ let imageUrl = $state('');
82
+ let imageWidth = $state('original');
83
+ let imageAlign = $state('left');
84
+ let imageAlt = $state('');
85
+ let imageFile = $state<File | null>(null);
86
+ let imagePreview = $state('');
87
+ let savedImageSelection = $state<Range | null>(null);
88
+ let selectedImage = $state<HTMLImageElement | null>(null);
89
+ let isImageEditPopupOpen = $state(false);
90
+ let editImageWidth = $state('');
91
+ let editImageAlign = $state('left');
92
+ let editImageAlt = $state('');
93
+ let isResizing = $state(false);
94
+ let resizeStartData = $state<{ startX: number; startY: number; startWidth: number; startHeight: number; handle: string } | null>(null);
95
+
96
+ // YouTube state
97
+ let isYoutubeDropdownOpen = $state(false);
98
+ let youtubeUrl = $state('');
99
+ let savedYoutubeSelection = $state<Range | null>(null);
100
+ let youtubeWidth = $state('100%');
101
+ let youtubeAlign = $state('center');
102
+ let selectedYoutube = $state<HTMLElement | null>(null);
103
+ let isYoutubeEditPopupOpen = $state(false);
104
+ let editYoutubeWidth = $state('100%');
105
+ let editYoutubeAlign = $state('center');
106
+ let isCodeView = $state(false);
107
+ let codeContent = $state('');
108
+ let originalHtml = $state('');
109
+ let savedEditorHeight = $state<number | null>(null);
110
+
111
+ // Undo/Redo history
112
+ let history = $state<string[]>([value]);
113
+ let historyIndex = $state(0);
114
+ let historyTimer: ReturnType<typeof setTimeout> | null = null;
115
+ let isUndoRedo = false;
116
+ let isComposing = false;
117
+ let justComposed = false;
118
+
119
+ // Table state
120
+ let isTableDropdownOpen = $state(false);
121
+ let tableRows = $state(0);
122
+ let tableCols = $state(0);
123
+ let savedTableSelection = $state<Range | null>(null);
124
+ let isTableContextMenuOpen = $state(false);
125
+ let tableContextMenuPosition = $state({ x: 0, y: 0 });
126
+ let selectedTableCell = $state<HTMLTableCellElement | null>(null);
127
+ let isTableCellColorOpen = $state(false);
128
+ let selectedTableCells = $state<HTMLTableCellElement[]>([]);
129
+ let selectionStartCell: HTMLTableCellElement | null = null;
130
+ let isSelectingCells = false;
131
+ let justFinishedDragging = false;
132
+ let isMouseDown = false;
133
+
134
+ // Refs
135
+ let editorRef = $state<HTMLDivElement | null>(null);
136
+ let codeEditorRef = $state<HTMLTextAreaElement | null>(null);
137
+ let containerRef = $state<HTMLDivElement | null>(null);
138
+ let fileInputRef = $state<HTMLInputElement | null>(null);
139
+ let imageFileInputRef = $state<HTMLInputElement | null>(null);
140
+ let tableContextMenuRef = $state<HTMLDivElement | null>(null);
141
+
142
+ let editorID = $state(`podo-editor-${Math.random().toString(36).slice(2, 9)}`);
143
+
144
+ // Toolbar config
145
+ const defaultToolbar: ToolbarItem[] = [
146
+ 'undo-redo', 'paragraph', 'text-style', 'color', 'align', 'list',
147
+ 'table', 'link', 'image', 'youtube', 'hr', 'format', 'code',
148
+ ];
149
+ const activeToolbar = $derived(toolbar || defaultToolbar);
150
+
151
+ const isToolbarItemEnabled = (item: ToolbarItem) => activeToolbar.includes(item);
152
+
153
+ // Color palette
154
+ const colorPalette = [
155
+ ['#ff0000', '#ff8000', '#ffff00', '#80ff00', '#00ffff', '#0080ff', '#0000ff', '#8000ff', '#ff00ff', '#ffffff', '#000000'],
156
+ ['#ffcccc', '#ffe0cc', '#ffffcc', '#e0ffcc', '#ccffff', '#cce0ff', '#ccccff', '#e0ccff', '#ffccff', '#f5f5f5', '#cccccc'],
157
+ ['#ff9999', '#ffcc99', '#ffff99', '#ccff99', '#99ffff', '#99ccff', '#9999ff', '#cc99ff', '#ff99ff', '#e6e6e6', '#999999'],
158
+ ['#ff6666', '#ffb366', '#ffff66', '#b3ff66', '#66ffff', '#66b3ff', '#6666ff', '#b366ff', '#ff66ff', '#d9d9d9', '#666666'],
159
+ ['#cc0000', '#cc6600', '#cccc00', '#66cc00', '#00cccc', '#0066cc', '#0000cc', '#6600cc', '#cc00cc', '#b3b3b3', '#333333'],
160
+ ['#800000', '#804000', '#808000', '#408000', '#008080', '#004080', '#000080', '#400080', '#800080', '#808080', '#1a1a1a'],
161
+ ];
162
+
163
+ // Align options
164
+ const alignOptions = [
165
+ { value: 'left', label: '왼쪽 정렬', icon: 'alignLeft' },
166
+ { value: 'center', label: '가운데 정렬', icon: 'alignCenter' },
167
+ { value: 'right', label: '오른쪽 정렬', icon: 'alignRight' },
168
+ ];
169
+
170
+ // Paragraph options
171
+ const paragraphOptions = [
172
+ { value: 'h1', label: '제목 1' },
173
+ { value: 'h2', label: '제목 2' },
174
+ { value: 'h3', label: '제목 3' },
175
+ { value: 'p', label: '본문', className: styles.pDefault },
176
+ { value: 'p1', label: 'P1', className: styles.p1Preview },
177
+ { value: 'p2', label: 'P2', className: styles.p2Preview },
178
+ { value: 'p3', label: 'P3', className: styles.p3Preview },
179
+ { value: 'p3_semibold', label: 'P3 Semibold', className: styles.p3_semiboldPreview },
180
+ { value: 'p4', label: 'P4', className: styles.p4Preview },
181
+ { value: 'p4_semibold', label: 'P4 Semibold', className: styles.p4_semiboldPreview },
182
+ { value: 'p5', label: 'P5', className: styles.p5Preview },
183
+ { value: 'p5_semibold', label: 'P5 Semibold', className: styles.p5_semiboldPreview },
184
+ ];
185
+
186
+ const getCurrentStyleLabel = () => {
187
+ const option = paragraphOptions.find(opt => opt.value === currentParagraphStyle);
188
+ return option ? option.label : '문단 형식';
189
+ };
190
+
191
+ const getCurrentAlignLabel = () => {
192
+ const option = alignOptions.find(opt => opt.value === currentAlign);
193
+ return option ? option.label : '왼쪽 정렬';
194
+ };
195
+
196
+ const getCurrentAlignIcon = () => {
197
+ const option = alignOptions.find(opt => opt.value === currentAlign);
198
+ return option ? styles[option.icon] : styles.alignLeft;
199
+ };
200
+
201
+ const validateHandler = (content: string) => {
202
+ if (validator) {
203
+ validate(content);
204
+ }
205
+ };
206
+
207
+ const detectCurrentAlign = () => {
208
+ if (document.queryCommandState('justifyLeft')) {
209
+ currentAlign = 'left';
210
+ } else if (document.queryCommandState('justifyCenter')) {
211
+ currentAlign = 'center';
212
+ } else if (document.queryCommandState('justifyRight')) {
213
+ currentAlign = 'right';
214
+ } else {
215
+ currentAlign = 'left';
216
+ }
217
+ };
218
+
219
+ const detectCurrentParagraphStyle = () => {
220
+ const selection = window.getSelection();
221
+ if (!selection || selection.rangeCount === 0) {
222
+ currentParagraphStyle = 'p';
223
+ return;
224
+ }
225
+
226
+ let container: Node | null = selection.getRangeAt(0).commonAncestorContainer;
227
+ if (container.nodeType === Node.TEXT_NODE) {
228
+ container = container.parentNode;
229
+ }
230
+
231
+ while (container && container !== editorRef) {
232
+ const element = container as Element;
233
+ if (element.tagName) {
234
+ const tagName = element.tagName.toLowerCase();
235
+
236
+ if (tagName === 'h1' || tagName === 'h2' || tagName === 'h3') {
237
+ currentParagraphStyle = tagName;
238
+ return;
239
+ }
240
+
241
+ if (tagName === 'p') {
242
+ if (element.className) {
243
+ const classNames = Object.keys(styles);
244
+ for (const cls of classNames) {
245
+ if (cls.match(/^p[1-5](_semibold)?$/) && element.classList.contains(styles[cls])) {
246
+ currentParagraphStyle = cls;
247
+ return;
248
+ }
249
+ }
250
+ }
251
+ currentParagraphStyle = 'p';
252
+ return;
253
+ }
254
+
255
+ if (tagName === 'div' || tagName === 'blockquote' || tagName === 'pre') {
256
+ currentParagraphStyle = 'p';
257
+ return;
258
+ }
259
+ }
260
+ container = (container as Element).parentNode;
261
+ }
262
+
263
+ currentParagraphStyle = 'p';
264
+ };
265
+
266
+ // Add to history with debounce
267
+ const addToHistory = (content: string) => {
268
+ if (historyTimer) {
269
+ clearTimeout(historyTimer);
270
+ }
271
+
272
+ historyTimer = setTimeout(() => {
273
+ const newHistory = history.slice(0, historyIndex + 1);
274
+
275
+ if (newHistory[newHistory.length - 1] === content) {
276
+ return;
277
+ }
278
+
279
+ const updated = [...newHistory, content];
280
+ if (updated.length > 200) {
281
+ updated.shift();
282
+ history = updated;
283
+ } else {
284
+ history = updated;
285
+ historyIndex = updated.length - 1;
286
+ }
287
+ }, 500);
288
+ };
289
+
290
+ // Undo
291
+ const performUndo = () => {
292
+ if (historyTimer) {
293
+ clearTimeout(historyTimer);
294
+ historyTimer = null;
295
+ }
296
+
297
+ if (historyIndex > 0) {
298
+ const newIndex = historyIndex - 1;
299
+ const content = history[newIndex];
300
+ historyIndex = newIndex;
301
+
302
+ if (editorRef) {
303
+ isUndoRedo = true;
304
+ editorRef.innerHTML = content;
305
+ value = content;
306
+ onchange?.(content);
307
+ detectCurrentParagraphStyle();
308
+ detectCurrentAlign();
309
+ setTimeout(() => {
310
+ isUndoRedo = false;
311
+ }, 0);
312
+ }
313
+ }
314
+ };
315
+
316
+ // Redo
317
+ const performRedo = () => {
318
+ if (historyTimer) {
319
+ clearTimeout(historyTimer);
320
+ historyTimer = null;
321
+ }
322
+
323
+ if (historyIndex < history.length - 1) {
324
+ const newIndex = historyIndex + 1;
325
+ const content = history[newIndex];
326
+ historyIndex = newIndex;
327
+
328
+ if (editorRef) {
329
+ isUndoRedo = true;
330
+ editorRef.innerHTML = content;
331
+ value = content;
332
+ onchange?.(content);
333
+ detectCurrentParagraphStyle();
334
+ detectCurrentAlign();
335
+ setTimeout(() => {
336
+ isUndoRedo = false;
337
+ }, 0);
338
+ }
339
+ }
340
+ };
341
+
342
+ const handleInput = () => {
343
+ if (isComposing) return;
344
+
345
+ if (justComposed) {
346
+ justComposed = false;
347
+ return;
348
+ }
349
+
350
+ if (editorRef) {
351
+ const content = editorRef.innerHTML;
352
+ value = content;
353
+ onchange?.(content);
354
+ validateHandler(content);
355
+ detectCurrentParagraphStyle();
356
+ detectCurrentAlign();
357
+ addToHistory(content);
358
+ }
359
+ };
360
+
361
+ const handleCompositionStart = () => {
362
+ isComposing = true;
363
+ };
364
+
365
+ const handleCompositionEnd = () => {
366
+ isComposing = false;
367
+ justComposed = true;
368
+
369
+ if (editorRef) {
370
+ const content = editorRef.innerHTML;
371
+ value = content;
372
+ onchange?.(content);
373
+ validateHandler(content);
374
+ detectCurrentParagraphStyle();
375
+ detectCurrentAlign();
376
+ addToHistory(content);
377
+ }
378
+ };
379
+
380
+ // Insert image into editor
381
+ const insertImageAtCursor = (src: string, alt = '') => {
382
+ const selection = window.getSelection();
383
+ if (!selection || selection.rangeCount === 0) return;
384
+
385
+ const range = selection.getRangeAt(0);
386
+ const img = document.createElement('img');
387
+ img.src = src;
388
+ img.alt = alt;
389
+ img.style.maxWidth = '100%';
390
+
391
+ range.deleteContents();
392
+ range.insertNode(img);
393
+
394
+ // Move cursor after image
395
+ const newRange = document.createRange();
396
+ newRange.setStartAfter(img);
397
+ newRange.collapse(true);
398
+ selection.removeAllRanges();
399
+ selection.addRange(newRange);
400
+
401
+ handleInput();
402
+ };
403
+
404
+ // Drag and drop handlers
405
+ const handleDragOver = (e: DragEvent) => {
406
+ e.preventDefault();
407
+ e.stopPropagation();
408
+ };
409
+
410
+ const handleDrop = (e: DragEvent) => {
411
+ e.preventDefault();
412
+ e.stopPropagation();
413
+
414
+ const files = e.dataTransfer?.files;
415
+ if (!files || files.length === 0) return;
416
+
417
+ // Process image files only
418
+ for (let i = 0; i < files.length; i++) {
419
+ const file = files[i];
420
+ if (file.type.startsWith('image/')) {
421
+ const reader = new FileReader();
422
+ reader.onload = (event) => {
423
+ const dataUrl = event.target?.result as string;
424
+ if (dataUrl) {
425
+ // Focus editor at drop position
426
+ editorRef?.focus();
427
+ insertImageAtCursor(dataUrl, file.name || 'dropped-image');
428
+ }
429
+ };
430
+ reader.readAsDataURL(file);
431
+ }
432
+ }
433
+ };
434
+
435
+ // Paste handler
436
+ const handlePaste = (e: ClipboardEvent) => {
437
+ const clipboardData = e.clipboardData;
438
+ if (!clipboardData) return;
439
+
440
+ // Check for image files in clipboard
441
+ const items = clipboardData.items;
442
+ if (items) {
443
+ for (let i = 0; i < items.length; i++) {
444
+ const item = items[i];
445
+ if (item.type.startsWith('image/')) {
446
+ e.preventDefault();
447
+ const file = item.getAsFile();
448
+ if (file) {
449
+ const reader = new FileReader();
450
+ reader.onload = (event) => {
451
+ const dataUrl = event.target?.result as string;
452
+ if (dataUrl) {
453
+ insertImageAtCursor(dataUrl, file.name || 'pasted-image');
454
+ }
455
+ };
456
+ reader.readAsDataURL(file);
457
+ }
458
+ return;
459
+ }
460
+ }
461
+ }
462
+
463
+ // Check for files (drag & drop or file paste)
464
+ const files = clipboardData.files;
465
+ if (files && files.length > 0) {
466
+ for (let i = 0; i < files.length; i++) {
467
+ const file = files[i];
468
+ if (file.type.startsWith('image/')) {
469
+ e.preventDefault();
470
+ const reader = new FileReader();
471
+ reader.onload = (event) => {
472
+ const dataUrl = event.target?.result as string;
473
+ if (dataUrl) {
474
+ insertImageAtCursor(dataUrl, file.name || 'pasted-image');
475
+ }
476
+ };
477
+ reader.readAsDataURL(file);
478
+ return;
479
+ }
480
+ }
481
+ }
482
+
483
+ // Handle HTML/text paste
484
+ e.preventDefault();
485
+
486
+ const html = clipboardData.getData('text/html');
487
+ const text = clipboardData.getData('text/plain');
488
+
489
+ if (html) {
490
+ const tempDiv = document.createElement('div');
491
+ tempDiv.innerHTML = html;
492
+
493
+ const allowedTags = ['P', 'BR', 'STRONG', 'B', 'EM', 'I', 'U', 'S', 'STRIKE', 'DEL',
494
+ 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE',
495
+ 'UL', 'OL', 'LI', 'A', 'IMG', 'SPAN', 'DIV', 'HR',
496
+ 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD'];
497
+
498
+ const cleanElement = (element: Element): Node | null => {
499
+ const tagName = element.tagName;
500
+
501
+ if (!allowedTags.includes(tagName)) {
502
+ const fragment = document.createDocumentFragment();
503
+ Array.from(element.childNodes).forEach(child => {
504
+ if (child.nodeType === Node.ELEMENT_NODE) {
505
+ const cleaned = cleanElement(child as Element);
506
+ if (cleaned) fragment.appendChild(cleaned);
507
+ } else if (child.nodeType === Node.TEXT_NODE) {
508
+ fragment.appendChild(child.cloneNode(true));
509
+ }
510
+ });
511
+ return fragment.childNodes.length > 0 ? fragment : null;
512
+ }
513
+
514
+ const newElement = element.cloneNode(false) as HTMLElement;
515
+ const attrs = Array.from(element.attributes);
516
+ attrs.forEach(attr => newElement.removeAttribute(attr.name));
517
+
518
+ if (tagName === 'A' && element.getAttribute('href')) {
519
+ newElement.setAttribute('href', element.getAttribute('href')!);
520
+ if (element.getAttribute('target')) {
521
+ newElement.setAttribute('target', element.getAttribute('target')!);
522
+ }
523
+ }
524
+
525
+ if (tagName === 'IMG') {
526
+ if (element.getAttribute('src')) {
527
+ newElement.setAttribute('src', element.getAttribute('src')!);
528
+ }
529
+ if (element.getAttribute('alt')) {
530
+ newElement.setAttribute('alt', element.getAttribute('alt')!);
531
+ }
532
+ }
533
+
534
+ if (tagName === 'SPAN' || tagName === 'P' || tagName === 'DIV') {
535
+ const style = (element as HTMLElement).style;
536
+ const allowedStyles: string[] = [];
537
+
538
+ if (style.color) allowedStyles.push(`color: ${style.color}`);
539
+ if (style.backgroundColor) allowedStyles.push(`background-color: ${style.backgroundColor}`);
540
+ if (style.textAlign) allowedStyles.push(`text-align: ${style.textAlign}`);
541
+
542
+ if (allowedStyles.length > 0) {
543
+ newElement.setAttribute('style', allowedStyles.join('; '));
544
+ }
545
+ }
546
+
547
+ Array.from(element.childNodes).forEach(child => {
548
+ if (child.nodeType === Node.ELEMENT_NODE) {
549
+ const cleaned = cleanElement(child as Element);
550
+ if (cleaned) newElement.appendChild(cleaned);
551
+ } else if (child.nodeType === Node.TEXT_NODE) {
552
+ newElement.appendChild(child.cloneNode(true));
553
+ }
554
+ });
555
+
556
+ return newElement;
557
+ };
558
+
559
+ const cleanedContent = document.createDocumentFragment();
560
+ Array.from(tempDiv.childNodes).forEach(child => {
561
+ if (child.nodeType === Node.ELEMENT_NODE) {
562
+ const cleaned = cleanElement(child as Element);
563
+ if (cleaned) cleanedContent.appendChild(cleaned);
564
+ } else if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) {
565
+ cleanedContent.appendChild(child.cloneNode(true));
566
+ }
567
+ });
568
+
569
+ const selection = window.getSelection();
570
+ if (selection && selection.rangeCount > 0) {
571
+ const range = selection.getRangeAt(0);
572
+ range.deleteContents();
573
+ range.insertNode(cleanedContent);
574
+ range.collapse(false);
575
+ selection.removeAllRanges();
576
+ selection.addRange(range);
577
+ }
578
+ } else if (text) {
579
+ document.execCommand('insertText', false, text);
580
+ }
581
+
582
+ handleInput();
583
+ };
584
+
585
+ // Text formatting commands
586
+ const execCommand = (command: string, value?: string) => {
587
+ document.execCommand(command, false, value);
588
+ editorRef?.focus();
589
+ handleInput();
590
+ };
591
+
592
+ const formatBold = () => execCommand('bold');
593
+ const formatItalic = () => execCommand('italic');
594
+ const formatUnderline = () => execCommand('underline');
595
+ const formatStrikethrough = () => execCommand('strikethrough');
596
+ const removeFormat = () => execCommand('removeFormat');
597
+
598
+ const setTextColor = (color: string) => {
599
+ execCommand('foreColor', color);
600
+ isTextColorOpen = false;
601
+ };
602
+
603
+ const setBackgroundColor = (color: string) => {
604
+ execCommand('hiliteColor', color);
605
+ isBgColorOpen = false;
606
+ };
607
+
608
+ const setAlignment = (align: string) => {
609
+ if (align === 'left') execCommand('justifyLeft');
610
+ else if (align === 'center') execCommand('justifyCenter');
611
+ else if (align === 'right') execCommand('justifyRight');
612
+ currentAlign = align;
613
+ isAlignDropdownOpen = false;
614
+ };
615
+
616
+ const insertList = (ordered: boolean) => {
617
+ execCommand(ordered ? 'insertOrderedList' : 'insertUnorderedList');
618
+ };
619
+
620
+ // Paragraph format
621
+ const setParagraphFormat = (format: string) => {
622
+ editorRef?.focus();
623
+ const selection = window.getSelection();
624
+
625
+ if (!selection || selection.rangeCount === 0) {
626
+ isParagraphDropdownOpen = false;
627
+ return;
628
+ }
629
+
630
+ if (format === 'h1' || format === 'h2' || format === 'h3') {
631
+ document.execCommand('formatBlock', false, format.toUpperCase());
632
+ } else if (format === 'p') {
633
+ document.execCommand('formatBlock', false, 'P');
634
+ } else if (format.startsWith('p') && (format.match(/p[1-5](_semibold)?/))) {
635
+ document.execCommand('formatBlock', false, 'P');
636
+
637
+ setTimeout(() => {
638
+ const sel = window.getSelection();
639
+ if (sel && sel.rangeCount > 0) {
640
+ let node: Node | null = sel.getRangeAt(0).startContainer;
641
+ if (node.nodeType === Node.TEXT_NODE) {
642
+ node = node.parentNode;
643
+ }
644
+
645
+ while (node && node !== editorRef) {
646
+ if ((node as Element).tagName === 'P') {
647
+ const p = node as HTMLParagraphElement;
648
+ Object.keys(styles).forEach(cls => {
649
+ if (cls.match(/^p[1-5](_semibold)?$/)) {
650
+ p.classList.remove(styles[cls]);
651
+ }
652
+ });
653
+ p.classList.add(styles[format]);
654
+ break;
655
+ }
656
+ node = node.parentNode;
657
+ }
658
+ }
659
+ }, 0);
660
+ }
661
+
662
+ currentParagraphStyle = format;
663
+ isParagraphDropdownOpen = false;
664
+ handleInput();
665
+ };
666
+
667
+ // Link functions
668
+ const insertLink = () => {
669
+ if (!linkUrl.trim()) return;
670
+
671
+ editorRef?.focus();
672
+
673
+ if (savedSelection) {
674
+ const selection = window.getSelection();
675
+ if (selection) {
676
+ selection.removeAllRanges();
677
+ selection.addRange(savedSelection);
678
+ }
679
+ }
680
+
681
+ const selection = window.getSelection();
682
+ if (selection && selection.rangeCount > 0) {
683
+ const range = selection.getRangeAt(0);
684
+ const selectedText = range.toString();
685
+
686
+ if (selectedText) {
687
+ const a = document.createElement('a');
688
+ a.href = linkUrl;
689
+ a.target = linkTarget;
690
+ a.textContent = selectedText;
691
+ range.deleteContents();
692
+ range.insertNode(a);
693
+ } else {
694
+ const a = document.createElement('a');
695
+ a.href = linkUrl;
696
+ a.target = linkTarget;
697
+ a.textContent = linkUrl;
698
+ range.insertNode(a);
699
+ }
700
+ }
701
+
702
+ linkUrl = '';
703
+ linkTarget = '_blank';
704
+ savedSelection = null;
705
+ isLinkDropdownOpen = false;
706
+ handleInput();
707
+ };
708
+
709
+ // HR insert
710
+ const insertHR = () => {
711
+ if (!editorRef) return;
712
+
713
+ editorRef.focus();
714
+ const selection = window.getSelection();
715
+
716
+ if (!selection || selection.rangeCount === 0) return;
717
+
718
+ const range = selection.getRangeAt(0);
719
+
720
+ const hr = document.createElement('hr');
721
+ hr.style.border = 'none';
722
+ hr.style.borderTop = '1px solid #ddd';
723
+ hr.style.margin = '10px 0';
724
+
725
+ const newP = document.createElement('p');
726
+ newP.innerHTML = '<br>';
727
+
728
+ range.deleteContents();
729
+ range.insertNode(hr);
730
+ hr.after(newP);
731
+
732
+ const newRange = document.createRange();
733
+ newRange.selectNodeContents(newP);
734
+ newRange.collapse(true);
735
+ selection.removeAllRanges();
736
+ selection.addRange(newRange);
737
+
738
+ editorRef.focus();
739
+ handleInput();
740
+ };
741
+
742
+ // Table insert
743
+ const insertTable = (rows: number, cols: number) => {
744
+ if (rows === 0 || cols === 0) return;
745
+
746
+ const table = document.createElement('table');
747
+ table.style.borderCollapse = 'collapse';
748
+ table.style.width = '100%';
749
+ table.style.margin = '10px 0';
750
+ table.setAttribute('border', '1');
751
+ table.style.border = '1px solid #ddd';
752
+
753
+ const tbody = document.createElement('tbody');
754
+
755
+ for (let i = 0; i < rows; i++) {
756
+ const tr = document.createElement('tr');
757
+
758
+ for (let j = 0; j < cols; j++) {
759
+ const td = document.createElement('td');
760
+ td.style.border = '1px solid #ddd';
761
+ td.style.padding = '8px';
762
+ td.style.minWidth = '50px';
763
+ td.innerHTML = '<br>';
764
+ tr.appendChild(td);
765
+ }
766
+
767
+ tbody.appendChild(tr);
768
+ }
769
+
770
+ table.appendChild(tbody);
771
+
772
+ if (editorRef) {
773
+ editorRef.focus();
774
+
775
+ const selection = window.getSelection();
776
+
777
+ if (savedTableSelection && selection) {
778
+ try {
779
+ selection.removeAllRanges();
780
+ selection.addRange(savedTableSelection);
781
+ } catch {
782
+ // ignore
783
+ }
784
+ }
785
+
786
+ if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) {
787
+ if (!editorRef.innerHTML || editorRef.innerHTML === '<br>') {
788
+ const p = document.createElement('p');
789
+ p.innerHTML = '<br>';
790
+ editorRef.appendChild(p);
791
+ }
792
+
793
+ const range = document.createRange();
794
+ range.selectNodeContents(editorRef);
795
+ range.collapse(false);
796
+ selection?.removeAllRanges();
797
+ selection?.addRange(range);
798
+ }
799
+
800
+ if (selection && selection.rangeCount > 0) {
801
+ const range = selection.getRangeAt(0);
802
+ range.deleteContents();
803
+ range.insertNode(table);
804
+
805
+ const newP = document.createElement('p');
806
+ newP.innerHTML = '<br>';
807
+ table.after(newP);
808
+
809
+ const firstCell = table.querySelector('td');
810
+ if (firstCell) {
811
+ const newRange = document.createRange();
812
+ newRange.selectNodeContents(firstCell);
813
+ newRange.collapse(true);
814
+ selection.removeAllRanges();
815
+ selection.addRange(newRange);
816
+ }
817
+ } else {
818
+ editorRef.appendChild(table);
819
+ }
820
+ }
821
+
822
+ isTableDropdownOpen = false;
823
+ tableRows = 0;
824
+ tableCols = 0;
825
+ savedTableSelection = null;
826
+
827
+ editorRef?.focus();
828
+ handleInput();
829
+ };
830
+
831
+ // Code view toggle
832
+ const toggleCodeView = () => {
833
+ if (!editorRef) return;
834
+
835
+ if (!isCodeView) {
836
+ // Switch to code view
837
+ savedEditorHeight = editorRef.offsetHeight;
838
+ originalHtml = editorRef.innerHTML;
839
+
840
+ // Format HTML
841
+ const formatted = originalHtml
842
+ .replace(/></g, '>\n<')
843
+ .replace(/(<\/?(?:p|div|h[1-6]|ul|ol|li|table|tr|td|th|tbody|thead|blockquote|pre|hr)[^>]*>)/gi, '\n$1\n')
844
+ .split('\n')
845
+ .filter(line => line.trim())
846
+ .join('\n');
847
+
848
+ codeContent = formatted;
849
+ isCodeView = true;
850
+ } else {
851
+ // Switch back to WYSIWYG
852
+ if (editorRef) {
853
+ editorRef.innerHTML = codeContent.replace(/\n/g, '');
854
+ handleInput();
855
+ }
856
+ isCodeView = false;
857
+ }
858
+ };
859
+
860
+ // Keydown handler
861
+ const handleKeyDown = (e: KeyboardEvent) => {
862
+ // Undo: Ctrl/Cmd + Z
863
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
864
+ e.preventDefault();
865
+ performUndo();
866
+ return;
867
+ }
868
+
869
+ // Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y
870
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
871
+ e.preventDefault();
872
+ performRedo();
873
+ return;
874
+ }
875
+
876
+ // Bold: Ctrl/Cmd + B
877
+ if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
878
+ e.preventDefault();
879
+ formatBold();
880
+ return;
881
+ }
882
+
883
+ // Italic: Ctrl/Cmd + I
884
+ if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
885
+ e.preventDefault();
886
+ formatItalic();
887
+ return;
888
+ }
889
+
890
+ // Underline: Ctrl/Cmd + U
891
+ if ((e.ctrlKey || e.metaKey) && e.key === 'u') {
892
+ e.preventDefault();
893
+ formatUnderline();
894
+ return;
895
+ }
896
+ };
897
+
898
+ // Click outside to close dropdowns
899
+ const handleClickOutside = (e: MouseEvent) => {
900
+ const target = e.target as HTMLElement;
901
+
902
+ if (!target.closest(`.${styles.paragraphDropdown}`) && !target.closest(`.${styles.paragraphButton}`)) {
903
+ isParagraphDropdownOpen = false;
904
+ }
905
+ if (!target.closest(`.${styles.colorDropdown}`) && !target.closest(`.${styles.colorButton}`)) {
906
+ isTextColorOpen = false;
907
+ isBgColorOpen = false;
908
+ }
909
+ if (!target.closest(`.${styles.alignDropdown}`) && !target.closest(`.${styles.alignButton}`)) {
910
+ isAlignDropdownOpen = false;
911
+ }
912
+ if (!target.closest(`.${styles.linkDropdown}`) && !target.closest(`.${styles.linkButton}`)) {
913
+ isLinkDropdownOpen = false;
914
+ }
915
+ if (!target.closest(`.${styles.tableDropdown}`) && !target.closest(`.${styles.tableButton}`)) {
916
+ isTableDropdownOpen = false;
917
+ }
918
+ if (!target.closest(`.${styles.imageDropdown}`) && !target.closest(`.${styles.imageButton}`)) {
919
+ isImageDropdownOpen = false;
920
+ }
921
+ if (!target.closest(`.${styles.youtubeDropdown}`) && !target.closest(`.${styles.youtubeButton}`)) {
922
+ isYoutubeDropdownOpen = false;
923
+ }
924
+ };
925
+
926
+ // Initialize value
927
+ $effect(() => {
928
+ if (editorRef && value !== editorRef.innerHTML && !isUndoRedo) {
929
+ editorRef.innerHTML = value;
930
+ }
931
+ });
932
+
933
+ onMount(() => {
934
+ if (editorRef && value) {
935
+ editorRef.innerHTML = value;
936
+ }
937
+
938
+ if (typeof document !== 'undefined') {
939
+ document.addEventListener('mousedown', handleClickOutside);
940
+ }
941
+ });
942
+
943
+ onDestroy(() => {
944
+ if (typeof document !== 'undefined') {
945
+ document.removeEventListener('mousedown', handleClickOutside);
946
+ }
947
+ if (historyTimer) {
948
+ clearTimeout(historyTimer);
949
+ }
950
+ });
951
+
952
+ // Computed styles
953
+ const editorStyle = $derived.by(() => {
954
+ const styles: Record<string, string> = { width };
955
+
956
+ if (height === 'contents') {
957
+ styles.height = 'auto';
958
+ } else {
959
+ styles.height = height;
960
+ }
961
+
962
+ if (minHeight) styles.minHeight = minHeight;
963
+ if (maxHeight) styles.maxHeight = maxHeight;
964
+
965
+ return Object.entries(styles).map(([k, v]) => `${k}: ${v}`).join('; ');
966
+ });
967
+ </script>
968
+
969
+ <div
970
+ bind:this={containerRef}
971
+ class="{styles.editor} {className}"
972
+ style="width: {width};"
973
+ {...rest}
974
+ >
975
+ <!-- Toolbar -->
976
+ <div class={styles.toolbar}>
977
+ <!-- Undo/Redo -->
978
+ {#if isToolbarItemEnabled('undo-redo')}
979
+ <div class={styles.toolbarGroup}>
980
+ <button
981
+ type="button"
982
+ class={styles.toolbarButton}
983
+ onclick={performUndo}
984
+ disabled={historyIndex <= 0}
985
+ title="실행 취소 (Ctrl+Z)"
986
+ >
987
+ <i class={styles.undo}></i>
988
+ </button>
989
+ <button
990
+ type="button"
991
+ class={styles.toolbarButton}
992
+ onclick={performRedo}
993
+ disabled={historyIndex >= history.length - 1}
994
+ title="다시 실행 (Ctrl+Y)"
995
+ >
996
+ <i class={styles.redo}></i>
997
+ </button>
998
+ </div>
999
+ {/if}
1000
+
1001
+ <!-- Paragraph format -->
1002
+ {#if isToolbarItemEnabled('paragraph')}
1003
+ <div class={styles.toolbarGroup}>
1004
+ <button
1005
+ type="button"
1006
+ class={styles.paragraphButton}
1007
+ onclick={() => isParagraphDropdownOpen = !isParagraphDropdownOpen}
1008
+ >
1009
+ <span>{getCurrentStyleLabel()}</span>
1010
+ <i class={styles.dropdownArrow}></i>
1011
+ </button>
1012
+ {#if isParagraphDropdownOpen}
1013
+ <div class={styles.paragraphDropdown}>
1014
+ {#each paragraphOptions as option}
1015
+ <button
1016
+ type="button"
1017
+ class="{styles.paragraphOption} {option.className || ''}"
1018
+ onclick={() => setParagraphFormat(option.value)}
1019
+ >
1020
+ {option.label}
1021
+ </button>
1022
+ {/each}
1023
+ </div>
1024
+ {/if}
1025
+ </div>
1026
+ {/if}
1027
+
1028
+ <!-- Text style -->
1029
+ {#if isToolbarItemEnabled('text-style')}
1030
+ <div class={styles.toolbarGroup}>
1031
+ <button type="button" class={styles.toolbarButton} onclick={formatBold} title="굵게 (Ctrl+B)">
1032
+ <i class={styles.bold}></i>
1033
+ </button>
1034
+ <button type="button" class={styles.toolbarButton} onclick={formatItalic} title="기울임 (Ctrl+I)">
1035
+ <i class={styles.italic}></i>
1036
+ </button>
1037
+ <button type="button" class={styles.toolbarButton} onclick={formatUnderline} title="밑줄 (Ctrl+U)">
1038
+ <i class={styles.underline}></i>
1039
+ </button>
1040
+ <button type="button" class={styles.toolbarButton} onclick={formatStrikethrough} title="취소선">
1041
+ <i class={styles.strikethrough}></i>
1042
+ </button>
1043
+ </div>
1044
+ {/if}
1045
+
1046
+ <!-- Color -->
1047
+ {#if isToolbarItemEnabled('color')}
1048
+ <div class={styles.toolbarGroup}>
1049
+ <div class={styles.colorButton}>
1050
+ <button
1051
+ type="button"
1052
+ class={styles.toolbarButton}
1053
+ onclick={() => { isTextColorOpen = !isTextColorOpen; isBgColorOpen = false; }}
1054
+ title="글꼴 색상"
1055
+ >
1056
+ <i class={styles.fontColor}></i>
1057
+ </button>
1058
+ {#if isTextColorOpen}
1059
+ <div class={styles.colorDropdown}>
1060
+ <div class={styles.colorPalette}>
1061
+ {#each colorPalette as row}
1062
+ <div class={styles.colorRow}>
1063
+ {#each row as color}
1064
+ <button
1065
+ type="button"
1066
+ class={styles.colorCell}
1067
+ style="background-color: {color};"
1068
+ onclick={() => setTextColor(color)}
1069
+ ></button>
1070
+ {/each}
1071
+ </div>
1072
+ {/each}
1073
+ </div>
1074
+ </div>
1075
+ {/if}
1076
+ </div>
1077
+ <div class={styles.colorButton}>
1078
+ <button
1079
+ type="button"
1080
+ class={styles.toolbarButton}
1081
+ onclick={() => { isBgColorOpen = !isBgColorOpen; isTextColorOpen = false; }}
1082
+ title="배경 색상"
1083
+ >
1084
+ <i class={styles.highlight}></i>
1085
+ </button>
1086
+ {#if isBgColorOpen}
1087
+ <div class={styles.colorDropdown}>
1088
+ <div class={styles.colorPalette}>
1089
+ {#each colorPalette as row}
1090
+ <div class={styles.colorRow}>
1091
+ {#each row as color}
1092
+ <button
1093
+ type="button"
1094
+ class={styles.colorCell}
1095
+ style="background-color: {color};"
1096
+ onclick={() => setBackgroundColor(color)}
1097
+ ></button>
1098
+ {/each}
1099
+ </div>
1100
+ {/each}
1101
+ </div>
1102
+ </div>
1103
+ {/if}
1104
+ </div>
1105
+ </div>
1106
+ {/if}
1107
+
1108
+ <!-- Align -->
1109
+ {#if isToolbarItemEnabled('align')}
1110
+ <div class={styles.toolbarGroup}>
1111
+ <div class={styles.alignButton}>
1112
+ <button
1113
+ type="button"
1114
+ class={styles.toolbarButton}
1115
+ onclick={() => isAlignDropdownOpen = !isAlignDropdownOpen}
1116
+ title={getCurrentAlignLabel()}
1117
+ >
1118
+ <span class={getCurrentAlignIcon()}></span>
1119
+ <i class={styles.dropdownArrow}></i>
1120
+ </button>
1121
+ {#if isAlignDropdownOpen}
1122
+ <div class={styles.alignDropdown}>
1123
+ {#each alignOptions as option}
1124
+ <button
1125
+ type="button"
1126
+ class={styles.alignOption}
1127
+ onclick={() => setAlignment(option.value)}
1128
+ >
1129
+ <span class={styles[option.icon]}></span>
1130
+ {option.label}
1131
+ </button>
1132
+ {/each}
1133
+ </div>
1134
+ {/if}
1135
+ </div>
1136
+ </div>
1137
+ {/if}
1138
+
1139
+ <!-- List -->
1140
+ {#if isToolbarItemEnabled('list')}
1141
+ <div class={styles.toolbarGroup}>
1142
+ <button type="button" class={styles.toolbarButton} onclick={() => insertList(false)} title="목록">
1143
+ <i class={styles.listUl}></i>
1144
+ </button>
1145
+ <button type="button" class={styles.toolbarButton} onclick={() => insertList(true)} title="번호 목록">
1146
+ <i class={styles.listOl}></i>
1147
+ </button>
1148
+ </div>
1149
+ {/if}
1150
+
1151
+ <!-- Table -->
1152
+ {#if isToolbarItemEnabled('table')}
1153
+ <div class={styles.toolbarGroup}>
1154
+ <div class={styles.tableButton}>
1155
+ <button
1156
+ type="button"
1157
+ class={styles.toolbarButton}
1158
+ onclick={() => {
1159
+ const selection = window.getSelection();
1160
+ if (selection && selection.rangeCount > 0) {
1161
+ savedTableSelection = selection.getRangeAt(0).cloneRange();
1162
+ }
1163
+ isTableDropdownOpen = !isTableDropdownOpen;
1164
+ }}
1165
+ title="표 삽입"
1166
+ >
1167
+ <i class={styles.table}></i>
1168
+ </button>
1169
+ {#if isTableDropdownOpen}
1170
+ <div class={styles.tableDropdown}>
1171
+ <div class={styles.tableGrid}>
1172
+ {#each Array(6) as _, row}
1173
+ <div class={styles.tableGridRow}>
1174
+ {#each Array(6) as _, col}
1175
+ <button
1176
+ type="button"
1177
+ class="{styles.tableGridCell} {row <= tableRows - 1 && col <= tableCols - 1 ? styles.selected : ''}"
1178
+ onmouseenter={() => { tableRows = row + 1; tableCols = col + 1; }}
1179
+ onclick={() => insertTable(row + 1, col + 1)}
1180
+ ></button>
1181
+ {/each}
1182
+ </div>
1183
+ {/each}
1184
+ </div>
1185
+ <div class={styles.tableSize}>{tableRows} x {tableCols}</div>
1186
+ </div>
1187
+ {/if}
1188
+ </div>
1189
+ </div>
1190
+ {/if}
1191
+
1192
+ <!-- Link -->
1193
+ {#if isToolbarItemEnabled('link')}
1194
+ <div class={styles.toolbarGroup}>
1195
+ <div class={styles.linkButton}>
1196
+ <button
1197
+ type="button"
1198
+ class={styles.toolbarButton}
1199
+ onclick={() => {
1200
+ const selection = window.getSelection();
1201
+ if (selection && selection.rangeCount > 0) {
1202
+ savedSelection = selection.getRangeAt(0).cloneRange();
1203
+ }
1204
+ isLinkDropdownOpen = !isLinkDropdownOpen;
1205
+ }}
1206
+ title="링크 삽입"
1207
+ >
1208
+ <i class={styles.link}></i>
1209
+ </button>
1210
+ {#if isLinkDropdownOpen}
1211
+ <div class={styles.linkDropdown}>
1212
+ <div class={styles.linkInput}>
1213
+ <input
1214
+ type="text"
1215
+ placeholder="URL을 입력하세요"
1216
+ bind:value={linkUrl}
1217
+ onkeydown={(e) => e.key === 'Enter' && insertLink()}
1218
+ />
1219
+ </div>
1220
+ <div class={styles.linkTarget}>
1221
+ <label>
1222
+ <input type="radio" bind:group={linkTarget} value="_blank" /> 새 탭
1223
+ </label>
1224
+ <label>
1225
+ <input type="radio" bind:group={linkTarget} value="_self" /> 현재 탭
1226
+ </label>
1227
+ </div>
1228
+ <button type="button" class={styles.linkInsertButton} onclick={insertLink}>
1229
+ 삽입
1230
+ </button>
1231
+ </div>
1232
+ {/if}
1233
+ </div>
1234
+ </div>
1235
+ {/if}
1236
+
1237
+ <!-- HR -->
1238
+ {#if isToolbarItemEnabled('hr')}
1239
+ <div class={styles.toolbarGroup}>
1240
+ <button type="button" class={styles.toolbarButton} onclick={insertHR} title="구분선">
1241
+ <i class={styles.hr}></i>
1242
+ </button>
1243
+ </div>
1244
+ {/if}
1245
+
1246
+ <!-- Format clear -->
1247
+ {#if isToolbarItemEnabled('format')}
1248
+ <div class={styles.toolbarGroup}>
1249
+ <button type="button" class={styles.toolbarButton} onclick={removeFormat} title="서식 지우기">
1250
+ <i class={styles.eraser}></i>
1251
+ </button>
1252
+ </div>
1253
+ {/if}
1254
+
1255
+ <!-- Code view -->
1256
+ {#if isToolbarItemEnabled('code')}
1257
+ <div class={styles.toolbarGroup}>
1258
+ <button
1259
+ type="button"
1260
+ class="{styles.toolbarButton} {isCodeView ? styles.active : ''}"
1261
+ onclick={toggleCodeView}
1262
+ title="코드 보기"
1263
+ >
1264
+ <i class={styles.code}></i>
1265
+ </button>
1266
+ </div>
1267
+ {/if}
1268
+ </div>
1269
+
1270
+ <!-- Editor content -->
1271
+ <div class={styles.editorWrapper} style={editorStyle}>
1272
+ {#if !isCodeView}
1273
+ <div
1274
+ bind:this={editorRef}
1275
+ id={editorID}
1276
+ class={styles.editorContent}
1277
+ contenteditable="true"
1278
+ data-placeholder={placeholder}
1279
+ oninput={handleInput}
1280
+ oncompositionstart={handleCompositionStart}
1281
+ oncompositionend={handleCompositionEnd}
1282
+ onpaste={handlePaste}
1283
+ ondragover={handleDragOver}
1284
+ ondrop={handleDrop}
1285
+ onkeydown={handleKeyDown}
1286
+ role="textbox"
1287
+ aria-multiline="true"
1288
+ style={resizable ? 'resize: vertical;' : ''}
1289
+ ></div>
1290
+ {:else}
1291
+ <textarea
1292
+ bind:this={codeEditorRef}
1293
+ class={styles.codeEditor}
1294
+ bind:value={codeContent}
1295
+ style="height: {savedEditorHeight}px;"
1296
+ ></textarea>
1297
+ {/if}
1298
+ </div>
1299
+
1300
+ <!-- Validation message -->
1301
+ {#if validator && $message}
1302
+ <div class="validator" role="alert">
1303
+ {$message}
1304
+ </div>
1305
+ {/if}
1306
+ </div>
1307
+
1308
+ <style>
1309
+ :global(.validator) {
1310
+ color: var(--color-danger);
1311
+ font-size: 12px;
1312
+ margin-top: 4px;
1313
+ }
1314
+ </style>