podo-ui 1.0.2 → 1.0.5
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.
- package/cdn/font/icon.woff +0 -0
- package/cdn/podo-datepicker.css +1 -1
- package/cdn/podo-datepicker.js +1 -1
- package/cdn/podo-datepicker.min.css +1 -1
- package/cdn/podo-datepicker.min.js +1 -1
- package/cdn/podo-ui.css +23 -2
- package/cdn/podo-ui.min.css +2 -2
- package/dist/react/atom/editor.d.ts.map +1 -1
- package/dist/react/atom/editor.js +1 -2
- package/dist/svelte/atom/Editor.svelte +2764 -160
- package/package.json +6 -6
- package/scss/icon/font/icon.woff +0 -0
- package/scss/icon/icon-name.scss +8 -1
|
@@ -68,6 +68,12 @@
|
|
|
68
68
|
let isBgColorOpen = $state(false);
|
|
69
69
|
let isAlignDropdownOpen = $state(false);
|
|
70
70
|
let currentAlign = $state('left');
|
|
71
|
+
|
|
72
|
+
// Text style states
|
|
73
|
+
let isBold = $state(false);
|
|
74
|
+
let isItalic = $state(false);
|
|
75
|
+
let isUnderline = $state(false);
|
|
76
|
+
let isStrikethrough = $state(false);
|
|
71
77
|
let isLinkDropdownOpen = $state(false);
|
|
72
78
|
let linkUrl = $state('');
|
|
73
79
|
let linkTarget = $state('_blank');
|
|
@@ -138,6 +144,24 @@
|
|
|
138
144
|
let fileInputRef = $state<HTMLInputElement | null>(null);
|
|
139
145
|
let imageFileInputRef = $state<HTMLInputElement | null>(null);
|
|
140
146
|
let tableContextMenuRef = $state<HTMLDivElement | null>(null);
|
|
147
|
+
let imageButtonRef = $state<HTMLDivElement | null>(null);
|
|
148
|
+
let youtubeButtonRef = $state<HTMLDivElement | null>(null);
|
|
149
|
+
let textColorButtonRef = $state<HTMLDivElement | null>(null);
|
|
150
|
+
let bgColorButtonRef = $state<HTMLDivElement | null>(null);
|
|
151
|
+
let alignButtonRef = $state<HTMLDivElement | null>(null);
|
|
152
|
+
let paragraphButtonRef = $state<HTMLDivElement | null>(null);
|
|
153
|
+
let linkButtonRef = $state<HTMLDivElement | null>(null);
|
|
154
|
+
let tableButtonRef = $state<HTMLDivElement | null>(null);
|
|
155
|
+
|
|
156
|
+
// Dropdown position state
|
|
157
|
+
let imageDropdownPos = $state({ top: 0, left: 0 });
|
|
158
|
+
let youtubeDropdownPos = $state({ top: 0, left: 0 });
|
|
159
|
+
let textColorDropdownPos = $state({ top: 0, left: 0 });
|
|
160
|
+
let bgColorDropdownPos = $state({ top: 0, left: 0 });
|
|
161
|
+
let alignDropdownPos = $state({ top: 0, left: 0 });
|
|
162
|
+
let paragraphDropdownPos = $state({ top: 0, left: 0 });
|
|
163
|
+
let linkDropdownPos = $state({ top: 0, left: 0 });
|
|
164
|
+
let tableDropdownPos = $state({ top: 0, left: 0 });
|
|
141
165
|
|
|
142
166
|
let editorID = $state(`podo-editor-${Math.random().toString(36).slice(2, 9)}`);
|
|
143
167
|
|
|
@@ -216,6 +240,13 @@
|
|
|
216
240
|
}
|
|
217
241
|
};
|
|
218
242
|
|
|
243
|
+
const detectTextStyles = () => {
|
|
244
|
+
isBold = document.queryCommandState('bold');
|
|
245
|
+
isItalic = document.queryCommandState('italic');
|
|
246
|
+
isUnderline = document.queryCommandState('underline');
|
|
247
|
+
isStrikethrough = document.queryCommandState('strikeThrough');
|
|
248
|
+
};
|
|
249
|
+
|
|
219
250
|
const detectCurrentParagraphStyle = () => {
|
|
220
251
|
const selection = window.getSelection();
|
|
221
252
|
if (!selection || selection.rangeCount === 0) {
|
|
@@ -306,6 +337,7 @@
|
|
|
306
337
|
onchange?.(content);
|
|
307
338
|
detectCurrentParagraphStyle();
|
|
308
339
|
detectCurrentAlign();
|
|
340
|
+
detectTextStyles();
|
|
309
341
|
setTimeout(() => {
|
|
310
342
|
isUndoRedo = false;
|
|
311
343
|
}, 0);
|
|
@@ -332,6 +364,7 @@
|
|
|
332
364
|
onchange?.(content);
|
|
333
365
|
detectCurrentParagraphStyle();
|
|
334
366
|
detectCurrentAlign();
|
|
367
|
+
detectTextStyles();
|
|
335
368
|
setTimeout(() => {
|
|
336
369
|
isUndoRedo = false;
|
|
337
370
|
}, 0);
|
|
@@ -354,6 +387,7 @@
|
|
|
354
387
|
validateHandler(content);
|
|
355
388
|
detectCurrentParagraphStyle();
|
|
356
389
|
detectCurrentAlign();
|
|
390
|
+
detectTextStyles();
|
|
357
391
|
addToHistory(content);
|
|
358
392
|
}
|
|
359
393
|
};
|
|
@@ -373,11 +407,12 @@
|
|
|
373
407
|
validateHandler(content);
|
|
374
408
|
detectCurrentParagraphStyle();
|
|
375
409
|
detectCurrentAlign();
|
|
410
|
+
detectTextStyles();
|
|
376
411
|
addToHistory(content);
|
|
377
412
|
}
|
|
378
413
|
};
|
|
379
414
|
|
|
380
|
-
// Insert image into editor
|
|
415
|
+
// Insert image into editor (simple version for drag/drop/paste)
|
|
381
416
|
const insertImageAtCursor = (src: string, alt = '') => {
|
|
382
417
|
const selection = window.getSelection();
|
|
383
418
|
if (!selection || selection.rangeCount === 0) return;
|
|
@@ -401,6 +436,296 @@
|
|
|
401
436
|
handleInput();
|
|
402
437
|
};
|
|
403
438
|
|
|
439
|
+
// Handle image file selection
|
|
440
|
+
const handleImageFileSelect = (e: Event) => {
|
|
441
|
+
const input = e.target as HTMLInputElement;
|
|
442
|
+
const file = input.files?.[0];
|
|
443
|
+
if (!file) return;
|
|
444
|
+
|
|
445
|
+
imageFile = file;
|
|
446
|
+
|
|
447
|
+
const reader = new FileReader();
|
|
448
|
+
reader.onload = (event) => {
|
|
449
|
+
imagePreview = event.target?.result as string;
|
|
450
|
+
};
|
|
451
|
+
reader.readAsDataURL(file);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// Insert image with options (from toolbar)
|
|
455
|
+
const insertImage = async () => {
|
|
456
|
+
let imageSrc = '';
|
|
457
|
+
|
|
458
|
+
// 파일이 업로드된 경우
|
|
459
|
+
if (imageFile && imagePreview) {
|
|
460
|
+
imageSrc = imagePreview;
|
|
461
|
+
}
|
|
462
|
+
// URL이 입력된 경우
|
|
463
|
+
else if (imageUrl) {
|
|
464
|
+
// URL 유효성 검사
|
|
465
|
+
try {
|
|
466
|
+
const testImg = new Image();
|
|
467
|
+
await new Promise((resolve, reject) => {
|
|
468
|
+
const timeout = setTimeout(() => {
|
|
469
|
+
reject(new Error('Timeout'));
|
|
470
|
+
}, 5000);
|
|
471
|
+
|
|
472
|
+
testImg.onload = () => {
|
|
473
|
+
clearTimeout(timeout);
|
|
474
|
+
resolve(true);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
testImg.onerror = () => {
|
|
478
|
+
clearTimeout(timeout);
|
|
479
|
+
reject(new Error('Load failed'));
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
testImg.src = imageUrl;
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
imageSrc = imageUrl;
|
|
486
|
+
} catch {
|
|
487
|
+
alert(`이미지를 불러올 수 없습니다.\n\n가능한 원인:\n1. 잘못된 이미지 URL\n2. CORS 정책으로 인한 차단\n3. 네트워크 연결 문제\n\nURL: ${imageUrl}`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (!imageSrc) return;
|
|
493
|
+
|
|
494
|
+
// 이미지 엘리먼트 생성
|
|
495
|
+
const img = document.createElement('img');
|
|
496
|
+
img.src = imageSrc;
|
|
497
|
+
img.alt = imageAlt || '';
|
|
498
|
+
img.style.display = 'inline-block';
|
|
499
|
+
img.style.verticalAlign = 'middle';
|
|
500
|
+
|
|
501
|
+
// 크기 설정
|
|
502
|
+
if (imageWidth === '100%') {
|
|
503
|
+
img.style.width = '100%';
|
|
504
|
+
img.style.height = 'auto';
|
|
505
|
+
} else if (imageWidth === '75%') {
|
|
506
|
+
img.style.width = '75%';
|
|
507
|
+
img.style.height = 'auto';
|
|
508
|
+
} else if (imageWidth === '50%') {
|
|
509
|
+
img.style.width = '50%';
|
|
510
|
+
img.style.height = 'auto';
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 컨테이너 div 생성 (정렬용)
|
|
514
|
+
const container = document.createElement('div');
|
|
515
|
+
container.style.textAlign = imageAlign;
|
|
516
|
+
container.appendChild(img);
|
|
517
|
+
|
|
518
|
+
// 에디터에 포커스 설정
|
|
519
|
+
if (editorRef) {
|
|
520
|
+
editorRef.focus();
|
|
521
|
+
|
|
522
|
+
const selection = window.getSelection();
|
|
523
|
+
|
|
524
|
+
// 저장된 선택 영역 복원
|
|
525
|
+
if (savedImageSelection && selection) {
|
|
526
|
+
try {
|
|
527
|
+
selection.removeAllRanges();
|
|
528
|
+
selection.addRange(savedImageSelection);
|
|
529
|
+
} catch {
|
|
530
|
+
// ignore
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 선택 영역 재확인
|
|
535
|
+
if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) {
|
|
536
|
+
if (!editorRef.innerHTML || editorRef.innerHTML === '<br>') {
|
|
537
|
+
const p = document.createElement('p');
|
|
538
|
+
p.innerHTML = '<br>';
|
|
539
|
+
editorRef.appendChild(p);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const range = document.createRange();
|
|
543
|
+
range.selectNodeContents(editorRef);
|
|
544
|
+
range.collapse(false);
|
|
545
|
+
selection?.removeAllRanges();
|
|
546
|
+
selection?.addRange(range);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 이미지 삽입
|
|
550
|
+
if (selection && selection.rangeCount > 0) {
|
|
551
|
+
const range = selection.getRangeAt(0);
|
|
552
|
+
range.deleteContents();
|
|
553
|
+
range.insertNode(container);
|
|
554
|
+
|
|
555
|
+
// 이미지 다음에 새 문단 추가
|
|
556
|
+
const newP = document.createElement('p');
|
|
557
|
+
newP.innerHTML = '<br>';
|
|
558
|
+
container.after(newP);
|
|
559
|
+
|
|
560
|
+
// 커서를 새 문단으로 이동
|
|
561
|
+
const newRange = document.createRange();
|
|
562
|
+
newRange.selectNodeContents(newP);
|
|
563
|
+
newRange.collapse(true);
|
|
564
|
+
selection.removeAllRanges();
|
|
565
|
+
selection.addRange(newRange);
|
|
566
|
+
} else {
|
|
567
|
+
editorRef.appendChild(container);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 상태 초기화
|
|
572
|
+
isImageDropdownOpen = false;
|
|
573
|
+
imageTabMode = 'file';
|
|
574
|
+
imageUrl = '';
|
|
575
|
+
imageFile = null;
|
|
576
|
+
imagePreview = '';
|
|
577
|
+
imageWidth = 'original';
|
|
578
|
+
imageAlign = 'left';
|
|
579
|
+
imageAlt = '';
|
|
580
|
+
savedImageSelection = null;
|
|
581
|
+
|
|
582
|
+
handleInput();
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
// Extract YouTube video ID from URL
|
|
586
|
+
const extractYoutubeVideoId = (url: string): string | null => {
|
|
587
|
+
const patterns = [
|
|
588
|
+
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
|
589
|
+
/youtube\.com\/watch\?.*v=([^&\n?#]+)/,
|
|
590
|
+
];
|
|
591
|
+
|
|
592
|
+
for (const pattern of patterns) {
|
|
593
|
+
const match = url.match(pattern);
|
|
594
|
+
if (match && match[1]) {
|
|
595
|
+
return match[1];
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return null;
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// Insert YouTube video
|
|
602
|
+
const insertYoutube = () => {
|
|
603
|
+
if (!youtubeUrl) return;
|
|
604
|
+
|
|
605
|
+
const videoId = extractYoutubeVideoId(youtubeUrl);
|
|
606
|
+
if (!videoId) {
|
|
607
|
+
alert('올바른 유튜브 URL을 입력해주세요.\n\n지원 형식:\n• https://www.youtube.com/watch?v=VIDEO_ID\n• https://youtu.be/VIDEO_ID');
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// YouTube 정렬 컨테이너 생성
|
|
612
|
+
const alignContainer = document.createElement('div');
|
|
613
|
+
alignContainer.style.textAlign = youtubeAlign;
|
|
614
|
+
alignContainer.style.margin = '20px 0';
|
|
615
|
+
|
|
616
|
+
// YouTube iframe 컨테이너 생성
|
|
617
|
+
const container = document.createElement('div');
|
|
618
|
+
container.className = 'youtube-container';
|
|
619
|
+
container.style.position = 'relative';
|
|
620
|
+
container.style.display = 'inline-block';
|
|
621
|
+
container.style.maxWidth = '100%';
|
|
622
|
+
|
|
623
|
+
// 크기 설정
|
|
624
|
+
if (youtubeWidth === 'original') {
|
|
625
|
+
container.style.width = '560px';
|
|
626
|
+
container.style.height = '315px';
|
|
627
|
+
} else if (youtubeWidth === '100%' || youtubeWidth === '75%' || youtubeWidth === '50%') {
|
|
628
|
+
container.style.width = youtubeWidth;
|
|
629
|
+
container.style.aspectRatio = '16 / 9';
|
|
630
|
+
} else {
|
|
631
|
+
container.style.width = youtubeWidth;
|
|
632
|
+
container.style.aspectRatio = '16 / 9';
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// iframe 생성
|
|
636
|
+
const iframe = document.createElement('iframe');
|
|
637
|
+
iframe.width = '100%';
|
|
638
|
+
iframe.height = '100%';
|
|
639
|
+
iframe.src = `https://www.youtube.com/embed/${videoId}`;
|
|
640
|
+
iframe.title = 'YouTube video player';
|
|
641
|
+
iframe.frameBorder = '0';
|
|
642
|
+
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
|
|
643
|
+
iframe.allowFullscreen = true;
|
|
644
|
+
iframe.style.width = '100%';
|
|
645
|
+
iframe.style.height = 'auto';
|
|
646
|
+
iframe.style.aspectRatio = '16 / 9';
|
|
647
|
+
iframe.style.display = 'block';
|
|
648
|
+
iframe.style.pointerEvents = 'none'; // 편집 모드에서 iframe 클릭 방지 (overlay가 클릭 캡처)
|
|
649
|
+
|
|
650
|
+
// 투명 오버레이 추가 (편집 모드에서 클릭 방지)
|
|
651
|
+
const overlay = document.createElement('div');
|
|
652
|
+
overlay.className = 'youtube-overlay';
|
|
653
|
+
overlay.style.position = 'absolute';
|
|
654
|
+
overlay.style.top = '0';
|
|
655
|
+
overlay.style.left = '0';
|
|
656
|
+
overlay.style.width = '100%';
|
|
657
|
+
overlay.style.height = '100%';
|
|
658
|
+
overlay.style.backgroundColor = 'transparent';
|
|
659
|
+
overlay.style.cursor = 'pointer';
|
|
660
|
+
overlay.style.zIndex = '1';
|
|
661
|
+
|
|
662
|
+
container.appendChild(iframe);
|
|
663
|
+
container.appendChild(overlay);
|
|
664
|
+
alignContainer.appendChild(container);
|
|
665
|
+
|
|
666
|
+
// 에디터에 포커스 설정
|
|
667
|
+
if (editorRef) {
|
|
668
|
+
editorRef.focus();
|
|
669
|
+
|
|
670
|
+
const selection = window.getSelection();
|
|
671
|
+
|
|
672
|
+
// 저장된 선택 영역 복원
|
|
673
|
+
if (savedYoutubeSelection && selection) {
|
|
674
|
+
try {
|
|
675
|
+
selection.removeAllRanges();
|
|
676
|
+
selection.addRange(savedYoutubeSelection);
|
|
677
|
+
} catch {
|
|
678
|
+
// ignore
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// 선택 영역 재확인
|
|
683
|
+
if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) {
|
|
684
|
+
if (!editorRef.innerHTML || editorRef.innerHTML === '<br>') {
|
|
685
|
+
const p = document.createElement('p');
|
|
686
|
+
p.innerHTML = '<br>';
|
|
687
|
+
editorRef.appendChild(p);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const range = document.createRange();
|
|
691
|
+
range.selectNodeContents(editorRef);
|
|
692
|
+
range.collapse(false);
|
|
693
|
+
selection?.removeAllRanges();
|
|
694
|
+
selection?.addRange(range);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// YouTube 삽입
|
|
698
|
+
if (selection && selection.rangeCount > 0) {
|
|
699
|
+
const range = selection.getRangeAt(0);
|
|
700
|
+
range.deleteContents();
|
|
701
|
+
range.insertNode(alignContainer);
|
|
702
|
+
|
|
703
|
+
// 다음에 새 문단 추가
|
|
704
|
+
const newP = document.createElement('p');
|
|
705
|
+
newP.innerHTML = '<br>';
|
|
706
|
+
alignContainer.after(newP);
|
|
707
|
+
|
|
708
|
+
// 커서를 새 문단으로 이동
|
|
709
|
+
const newRange = document.createRange();
|
|
710
|
+
newRange.selectNodeContents(newP);
|
|
711
|
+
newRange.collapse(true);
|
|
712
|
+
selection.removeAllRanges();
|
|
713
|
+
selection.addRange(newRange);
|
|
714
|
+
} else {
|
|
715
|
+
editorRef.appendChild(alignContainer);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// 상태 초기화
|
|
720
|
+
isYoutubeDropdownOpen = false;
|
|
721
|
+
youtubeUrl = '';
|
|
722
|
+
youtubeWidth = '100%';
|
|
723
|
+
youtubeAlign = 'center';
|
|
724
|
+
savedYoutubeSelection = null;
|
|
725
|
+
|
|
726
|
+
handleInput();
|
|
727
|
+
};
|
|
728
|
+
|
|
404
729
|
// Drag and drop handlers
|
|
405
730
|
const handleDragOver = (e: DragEvent) => {
|
|
406
731
|
e.preventDefault();
|
|
@@ -531,18 +856,26 @@
|
|
|
531
856
|
}
|
|
532
857
|
}
|
|
533
858
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
859
|
+
// Table 요소에 에디터 기본 스타일 적용 (insertTable과 동일)
|
|
860
|
+
if (tagName === 'TABLE') {
|
|
861
|
+
newElement.style.borderCollapse = 'collapse';
|
|
862
|
+
newElement.style.width = '100%';
|
|
863
|
+
newElement.style.margin = '10px 0';
|
|
864
|
+
newElement.setAttribute('border', '1');
|
|
865
|
+
newElement.style.border = '1px solid #ddd';
|
|
866
|
+
}
|
|
867
|
+
if (tagName === 'TH' || tagName === 'TD') {
|
|
868
|
+
newElement.style.border = '1px solid #ddd';
|
|
869
|
+
newElement.style.padding = '8px';
|
|
870
|
+
if (tagName === 'TD') {
|
|
871
|
+
newElement.style.minWidth = '50px';
|
|
544
872
|
}
|
|
545
873
|
}
|
|
874
|
+
if (tagName === 'TH') {
|
|
875
|
+
newElement.style.fontWeight = 'bold';
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// 붙여넣기된 요소의 style 속성은 모두 제거 (위 테이블 기본 스타일만 유지)
|
|
546
879
|
|
|
547
880
|
Array.from(element.childNodes).forEach(child => {
|
|
548
881
|
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
@@ -576,7 +909,66 @@
|
|
|
576
909
|
selection.addRange(range);
|
|
577
910
|
}
|
|
578
911
|
} else if (text) {
|
|
579
|
-
|
|
912
|
+
const selection = window.getSelection();
|
|
913
|
+
if (selection && selection.rangeCount > 0) {
|
|
914
|
+
const range = selection.getRangeAt(0);
|
|
915
|
+
range.deleteContents();
|
|
916
|
+
|
|
917
|
+
// 텍스트를 줄 단위로 분리 (연속된 줄바꿈은 문단 구분으로 처리)
|
|
918
|
+
const lines = text.split('\n');
|
|
919
|
+
const paragraphs: string[][] = [];
|
|
920
|
+
let currentParagraph: string[] = [];
|
|
921
|
+
|
|
922
|
+
lines.forEach((line) => {
|
|
923
|
+
if (line.trim() === '') {
|
|
924
|
+
// 빈 줄이면 현재 문단을 저장하고 새 문단 시작
|
|
925
|
+
if (currentParagraph.length > 0) {
|
|
926
|
+
paragraphs.push(currentParagraph);
|
|
927
|
+
currentParagraph = [];
|
|
928
|
+
}
|
|
929
|
+
} else {
|
|
930
|
+
currentParagraph.push(line);
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// 마지막 문단 추가
|
|
935
|
+
if (currentParagraph.length > 0) {
|
|
936
|
+
paragraphs.push(currentParagraph);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// 문단이 없으면 빈 문자열 처리
|
|
940
|
+
if (paragraphs.length === 0) {
|
|
941
|
+
range.insertNode(document.createTextNode(text));
|
|
942
|
+
range.collapse(false);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const fragment = document.createDocumentFragment();
|
|
947
|
+
|
|
948
|
+
paragraphs.forEach((paragraph) => {
|
|
949
|
+
if (paragraph.length === 1) {
|
|
950
|
+
// 한 줄짜리 문단은 <p> 태그로 감싸기
|
|
951
|
+
const p = document.createElement('p');
|
|
952
|
+
p.textContent = paragraph[0];
|
|
953
|
+
fragment.appendChild(p);
|
|
954
|
+
} else if (paragraph.length > 1) {
|
|
955
|
+
// 여러 줄은 <p> 태그로 감싸고 내부는 <br>로 구분
|
|
956
|
+
const p = document.createElement('p');
|
|
957
|
+
paragraph.forEach((line, lIndex) => {
|
|
958
|
+
p.appendChild(document.createTextNode(line));
|
|
959
|
+
if (lIndex < paragraph.length - 1) {
|
|
960
|
+
p.appendChild(document.createElement('br'));
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
fragment.appendChild(p);
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
range.insertNode(fragment);
|
|
968
|
+
range.collapse(false);
|
|
969
|
+
selection.removeAllRanges();
|
|
970
|
+
selection.addRange(range);
|
|
971
|
+
}
|
|
580
972
|
}
|
|
581
973
|
|
|
582
974
|
handleInput();
|
|
@@ -584,25 +976,227 @@
|
|
|
584
976
|
|
|
585
977
|
// Text formatting commands
|
|
586
978
|
const execCommand = (command: string, value?: string) => {
|
|
979
|
+
// undo/redo는 커스텀 함수 사용
|
|
980
|
+
if (command === 'undo') {
|
|
981
|
+
performUndo();
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (command === 'redo') {
|
|
985
|
+
performRedo();
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// bold, italic, underline, strikeThrough일 때 선택 영역이 없으면 아무것도 하지 않음
|
|
990
|
+
if (['bold', 'italic', 'underline', 'strikeThrough'].includes(command)) {
|
|
991
|
+
const selection = window.getSelection();
|
|
992
|
+
if (selection && selection.isCollapsed) {
|
|
993
|
+
// 선택 영역이 없으면 실행하지 않음
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
587
998
|
document.execCommand(command, false, value);
|
|
588
999
|
editorRef?.focus();
|
|
589
1000
|
handleInput();
|
|
590
1001
|
};
|
|
591
1002
|
|
|
592
|
-
const formatBold = () =>
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const
|
|
1003
|
+
const formatBold = () => {
|
|
1004
|
+
execCommand('bold');
|
|
1005
|
+
detectTextStyles();
|
|
1006
|
+
};
|
|
1007
|
+
const formatItalic = () => {
|
|
1008
|
+
execCommand('italic');
|
|
1009
|
+
detectTextStyles();
|
|
1010
|
+
};
|
|
1011
|
+
const formatUnderline = () => {
|
|
1012
|
+
execCommand('underline');
|
|
1013
|
+
detectTextStyles();
|
|
1014
|
+
};
|
|
1015
|
+
const formatStrikethrough = () => {
|
|
1016
|
+
execCommand('strikeThrough');
|
|
1017
|
+
detectTextStyles();
|
|
1018
|
+
};
|
|
1019
|
+
const removeFormat = () => {
|
|
1020
|
+
execCommand('removeFormat');
|
|
1021
|
+
detectTextStyles();
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
const applyColorStyle = (styleProperty: string, color: string) => {
|
|
1025
|
+
// 저장된 선택 영역 복원
|
|
1026
|
+
if (savedSelection) {
|
|
1027
|
+
const selection = window.getSelection();
|
|
1028
|
+
if (selection) {
|
|
1029
|
+
selection.removeAllRanges();
|
|
1030
|
+
selection.addRange(savedSelection);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const selection = window.getSelection();
|
|
1035
|
+
if (!selection || selection.isCollapsed) {
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const range = selection.getRangeAt(0);
|
|
1040
|
+
|
|
1041
|
+
// 선택 영역에 포함된 모든 표 셀 찾기
|
|
1042
|
+
const getSelectedTableCells = (): HTMLTableCellElement[] => {
|
|
1043
|
+
const cells: HTMLTableCellElement[] = [];
|
|
1044
|
+
const container = range.commonAncestorContainer;
|
|
1045
|
+
|
|
1046
|
+
// 컨테이너가 표인지 확인
|
|
1047
|
+
let tableElement: HTMLElement | null = null;
|
|
1048
|
+
let current = container.nodeType === Node.TEXT_NODE ? container.parentElement : container as HTMLElement;
|
|
1049
|
+
|
|
1050
|
+
while (current && current !== editorRef) {
|
|
1051
|
+
if (current.tagName === 'TABLE' || current.tagName === 'TBODY' || current.tagName === 'TR') {
|
|
1052
|
+
// 상위 table 요소 찾기
|
|
1053
|
+
let table = current;
|
|
1054
|
+
while (table && table.tagName !== 'TABLE') {
|
|
1055
|
+
table = table.parentElement as HTMLElement;
|
|
1056
|
+
}
|
|
1057
|
+
tableElement = table;
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
current = current.parentElement;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (!tableElement) return cells;
|
|
1064
|
+
|
|
1065
|
+
// 표 내의 모든 셀 확인
|
|
1066
|
+
const allCells = tableElement.querySelectorAll('td, th');
|
|
1067
|
+
allCells.forEach(cell => {
|
|
1068
|
+
if (range.intersectsNode(cell)) {
|
|
1069
|
+
cells.push(cell as HTMLTableCellElement);
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
return cells;
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
const selectedCells = getSelectedTableCells();
|
|
1077
|
+
|
|
1078
|
+
// 여러 표 셀이 선택된 경우
|
|
1079
|
+
if (selectedCells.length > 1) {
|
|
1080
|
+
selectedCells.forEach(cell => {
|
|
1081
|
+
// 각 셀의 모든 내용을 span으로 감싸기
|
|
1082
|
+
const cellContents = Array.from(cell.childNodes);
|
|
1083
|
+
|
|
1084
|
+
cellContents.forEach(node => {
|
|
1085
|
+
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
|
|
1086
|
+
// 텍스트 노드를 span으로 감싸기
|
|
1087
|
+
const span = document.createElement('span');
|
|
1088
|
+
if (styleProperty === 'color') {
|
|
1089
|
+
span.style.color = color;
|
|
1090
|
+
} else if (styleProperty === 'background-color') {
|
|
1091
|
+
span.style.backgroundColor = color;
|
|
1092
|
+
}
|
|
1093
|
+
span.textContent = node.textContent;
|
|
1094
|
+
cell.replaceChild(span, node);
|
|
1095
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1096
|
+
// 기존 요소에 스타일 적용
|
|
1097
|
+
const element = node as HTMLElement;
|
|
1098
|
+
if (styleProperty === 'color') {
|
|
1099
|
+
element.style.color = color;
|
|
1100
|
+
} else if (styleProperty === 'background-color') {
|
|
1101
|
+
element.style.backgroundColor = color;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
// 선택 해제
|
|
1108
|
+
selection.removeAllRanges();
|
|
1109
|
+
editorRef?.focus();
|
|
1110
|
+
handleInput();
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// 단일 셀 내부 또는 일반 텍스트
|
|
1115
|
+
const commonAncestor = range.commonAncestorContainer;
|
|
1116
|
+
|
|
1117
|
+
// 선택 영역이 표 셀 내부인지 확인
|
|
1118
|
+
const isInTableCell = (node: Node): boolean => {
|
|
1119
|
+
let current = node.nodeType === Node.TEXT_NODE ? node.parentElement : node as Element;
|
|
1120
|
+
while (current && current !== editorRef) {
|
|
1121
|
+
if (current.tagName === 'TD' || current.tagName === 'TH') {
|
|
1122
|
+
return true;
|
|
1123
|
+
}
|
|
1124
|
+
current = current.parentElement;
|
|
1125
|
+
}
|
|
1126
|
+
return false;
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
// 표 셀 내부에서의 색상 변경 (단일 셀)
|
|
1130
|
+
if (isInTableCell(commonAncestor)) {
|
|
1131
|
+
try {
|
|
1132
|
+
const contents = range.extractContents();
|
|
1133
|
+
const span = document.createElement('span');
|
|
1134
|
+
|
|
1135
|
+
if (styleProperty === 'color') {
|
|
1136
|
+
span.style.color = color;
|
|
1137
|
+
} else if (styleProperty === 'background-color') {
|
|
1138
|
+
span.style.backgroundColor = color;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
span.appendChild(contents);
|
|
1142
|
+
range.insertNode(span);
|
|
1143
|
+
|
|
1144
|
+
// 커서 위치 조정
|
|
1145
|
+
range.setStartAfter(span);
|
|
1146
|
+
range.collapse(true);
|
|
1147
|
+
selection.removeAllRanges();
|
|
1148
|
+
selection.addRange(range);
|
|
1149
|
+
|
|
1150
|
+
editorRef?.focus();
|
|
1151
|
+
handleInput();
|
|
1152
|
+
return;
|
|
1153
|
+
} catch {
|
|
1154
|
+
// 오류 무시
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// 일반 텍스트에 대한 색상 변경
|
|
1159
|
+
const span = document.createElement('span');
|
|
1160
|
+
|
|
1161
|
+
try {
|
|
1162
|
+
const contents = range.extractContents();
|
|
1163
|
+
|
|
1164
|
+
if (styleProperty === 'color') {
|
|
1165
|
+
span.setAttribute('style', `color: ${color} !important;`);
|
|
1166
|
+
} else if (styleProperty === 'background-color') {
|
|
1167
|
+
span.setAttribute('style', `background-color: ${color} !important;`);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
span.appendChild(contents);
|
|
1171
|
+
range.insertNode(span);
|
|
1172
|
+
|
|
1173
|
+
range.selectNodeContents(span);
|
|
1174
|
+
range.collapse(false);
|
|
1175
|
+
selection.removeAllRanges();
|
|
1176
|
+
selection.addRange(range);
|
|
1177
|
+
|
|
1178
|
+
} catch {
|
|
1179
|
+
if (styleProperty === 'color') {
|
|
1180
|
+
document.execCommand('foreColor', false, color);
|
|
1181
|
+
} else {
|
|
1182
|
+
document.execCommand('hiliteColor', false, color);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
editorRef?.focus();
|
|
1187
|
+
handleInput();
|
|
1188
|
+
};
|
|
597
1189
|
|
|
598
1190
|
const setTextColor = (color: string) => {
|
|
599
|
-
|
|
1191
|
+
applyColorStyle('color', color);
|
|
600
1192
|
isTextColorOpen = false;
|
|
1193
|
+
savedSelection = null;
|
|
601
1194
|
};
|
|
602
1195
|
|
|
603
1196
|
const setBackgroundColor = (color: string) => {
|
|
604
|
-
|
|
1197
|
+
applyColorStyle('background-color', color);
|
|
605
1198
|
isBgColorOpen = false;
|
|
1199
|
+
savedSelection = null;
|
|
606
1200
|
};
|
|
607
1201
|
|
|
608
1202
|
const setAlignment = (align: string) => {
|
|
@@ -628,11 +1222,11 @@
|
|
|
628
1222
|
}
|
|
629
1223
|
|
|
630
1224
|
if (format === 'h1' || format === 'h2' || format === 'h3') {
|
|
631
|
-
document.execCommand('formatBlock', false, format
|
|
1225
|
+
document.execCommand('formatBlock', false, format);
|
|
632
1226
|
} else if (format === 'p') {
|
|
633
|
-
document.execCommand('formatBlock', false, '
|
|
1227
|
+
document.execCommand('formatBlock', false, 'p');
|
|
634
1228
|
} else if (format.startsWith('p') && (format.match(/p[1-5](_semibold)?/))) {
|
|
635
|
-
document.execCommand('formatBlock', false, '
|
|
1229
|
+
document.execCommand('formatBlock', false, 'p');
|
|
636
1230
|
|
|
637
1231
|
setTimeout(() => {
|
|
638
1232
|
const sel = window.getSelection();
|
|
@@ -706,6 +1300,42 @@
|
|
|
706
1300
|
handleInput();
|
|
707
1301
|
};
|
|
708
1302
|
|
|
1303
|
+
// Link edit
|
|
1304
|
+
const updateLink = () => {
|
|
1305
|
+
if (selectedLinkElement && editLinkUrl) {
|
|
1306
|
+
selectedLinkElement.href = editLinkUrl;
|
|
1307
|
+
|
|
1308
|
+
if (editLinkTarget === '_blank') {
|
|
1309
|
+
selectedLinkElement.target = '_blank';
|
|
1310
|
+
selectedLinkElement.rel = 'noopener noreferrer';
|
|
1311
|
+
} else {
|
|
1312
|
+
selectedLinkElement.removeAttribute('target');
|
|
1313
|
+
selectedLinkElement.removeAttribute('rel');
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
isEditLinkPopupOpen = false;
|
|
1317
|
+
selectedLinkElement = null;
|
|
1318
|
+
editorRef?.focus();
|
|
1319
|
+
handleInput();
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
// Link remove
|
|
1324
|
+
const removeLink = () => {
|
|
1325
|
+
if (selectedLinkElement) {
|
|
1326
|
+
const parent = selectedLinkElement.parentNode;
|
|
1327
|
+
const textContent = selectedLinkElement.textContent || '';
|
|
1328
|
+
const textNode = document.createTextNode(textContent);
|
|
1329
|
+
|
|
1330
|
+
parent?.replaceChild(textNode, selectedLinkElement);
|
|
1331
|
+
|
|
1332
|
+
isEditLinkPopupOpen = false;
|
|
1333
|
+
selectedLinkElement = null;
|
|
1334
|
+
editorRef?.focus();
|
|
1335
|
+
handleInput();
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
|
|
709
1339
|
// HR insert
|
|
710
1340
|
const insertHR = () => {
|
|
711
1341
|
if (!editorRef) return;
|
|
@@ -828,37 +1458,831 @@
|
|
|
828
1458
|
handleInput();
|
|
829
1459
|
};
|
|
830
1460
|
|
|
831
|
-
//
|
|
832
|
-
const
|
|
833
|
-
|
|
1461
|
+
// Table context menu handler
|
|
1462
|
+
const handleTableContextMenu = (e: MouseEvent) => {
|
|
1463
|
+
const target = e.target as HTMLElement;
|
|
1464
|
+
const cell = target.closest('td, th') as HTMLTableCellElement;
|
|
834
1465
|
|
|
835
|
-
if (
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
originalHtml = editorRef.innerHTML;
|
|
1466
|
+
if (cell && editorRef?.contains(cell)) {
|
|
1467
|
+
e.preventDefault();
|
|
1468
|
+
e.stopPropagation();
|
|
839
1469
|
|
|
840
|
-
//
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1470
|
+
// 선택된 셀이 없거나, 우클릭한 셀이 선택 영역에 포함되지 않은 경우
|
|
1471
|
+
if (selectedTableCells.length === 0 || !selectedTableCells.includes(cell)) {
|
|
1472
|
+
clearCellSelection();
|
|
1473
|
+
selectedTableCell = cell;
|
|
1474
|
+
} else {
|
|
1475
|
+
// 선택된 셀들 중 하나를 우클릭한 경우, 첫 번째 셀을 대표로 사용
|
|
1476
|
+
selectedTableCell = selectedTableCells[0];
|
|
1477
|
+
}
|
|
847
1478
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1479
|
+
tableContextMenuPosition = { x: e.clientX, y: e.clientY };
|
|
1480
|
+
isTableContextMenuOpen = true;
|
|
1481
|
+
isTableCellColorOpen = false;
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
// Clear cell selection
|
|
1486
|
+
const clearCellSelection = () => {
|
|
1487
|
+
selectedTableCells.forEach(cell => cell.classList.remove('selected-cell'));
|
|
1488
|
+
selectedTableCells = [];
|
|
1489
|
+
selectionStartCell = null;
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1492
|
+
// 다중 셀 선택 범위 계산
|
|
1493
|
+
const getCellsInRange = (startCell: HTMLTableCellElement, endCell: HTMLTableCellElement): HTMLTableCellElement[] => {
|
|
1494
|
+
const table = startCell.closest('table');
|
|
1495
|
+
if (!table) return [];
|
|
1496
|
+
|
|
1497
|
+
const tbody = table.querySelector('tbody');
|
|
1498
|
+
if (!tbody) return [];
|
|
1499
|
+
|
|
1500
|
+
const startRow = startCell.parentElement as HTMLTableRowElement;
|
|
1501
|
+
const endRow = endCell.parentElement as HTMLTableRowElement;
|
|
1502
|
+
|
|
1503
|
+
const startRowIndex = Array.from(tbody.rows).indexOf(startRow);
|
|
1504
|
+
const endRowIndex = Array.from(tbody.rows).indexOf(endRow);
|
|
1505
|
+
const startColIndex = startCell.cellIndex;
|
|
1506
|
+
const endColIndex = endCell.cellIndex;
|
|
1507
|
+
|
|
1508
|
+
const minRow = Math.min(startRowIndex, endRowIndex);
|
|
1509
|
+
const maxRow = Math.max(startRowIndex, endRowIndex);
|
|
1510
|
+
const minCol = Math.min(startColIndex, endColIndex);
|
|
1511
|
+
const maxCol = Math.max(startColIndex, endColIndex);
|
|
1512
|
+
|
|
1513
|
+
const cells: HTMLTableCellElement[] = [];
|
|
1514
|
+
for (let r = minRow; r <= maxRow; r++) {
|
|
1515
|
+
const row = tbody.rows[r];
|
|
1516
|
+
for (let c = minCol; c <= maxCol; c++) {
|
|
1517
|
+
if (row.cells[c]) {
|
|
1518
|
+
cells.push(row.cells[c]);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
return cells;
|
|
1524
|
+
};
|
|
1525
|
+
|
|
1526
|
+
// 표 셀 마우스 다운 (드래그 선택 시작)
|
|
1527
|
+
const handleCellMouseDown = (e: MouseEvent) => {
|
|
1528
|
+
const target = e.target as HTMLElement;
|
|
1529
|
+
const cell = target.closest('td') as HTMLTableCellElement;
|
|
1530
|
+
|
|
1531
|
+
if (cell && editorRef?.contains(cell)) {
|
|
1532
|
+
// 이미지나 이미지 컨테이너를 드래그하는 경우 셀 선택 방지
|
|
1533
|
+
if (target.tagName === 'IMG' || target.classList.contains('image-container')) {
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// 마우스 다운 상태 설정
|
|
1538
|
+
isMouseDown = true;
|
|
1539
|
+
|
|
1540
|
+
// 드래그 시작 셀 설정
|
|
1541
|
+
selectionStartCell = cell;
|
|
1542
|
+
|
|
1543
|
+
// 이미 선택된 셀을 클릭한 경우 선택 유지
|
|
1544
|
+
const isAlreadySelected = cell.classList.contains('selected-cell');
|
|
1545
|
+
|
|
1546
|
+
// 새로운 셀을 클릭하거나 Shift 키를 누르지 않은 경우에만 기존 선택 해제
|
|
1547
|
+
if (!isAlreadySelected && !e.shiftKey) {
|
|
1548
|
+
const allCells = editorRef.querySelectorAll('.selected-cell');
|
|
1549
|
+
allCells.forEach(c => c.classList.remove('selected-cell'));
|
|
1550
|
+
selectedTableCells = [];
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
|
|
1555
|
+
// 표 셀 마우스 이동 (드래그 선택 중)
|
|
1556
|
+
const handleCellMouseMove = (e: MouseEvent) => {
|
|
1557
|
+
const target = e.target as HTMLElement;
|
|
1558
|
+
const cell = target.closest('td') as HTMLTableCellElement;
|
|
1559
|
+
|
|
1560
|
+
if (!cell || !editorRef?.contains(cell)) return;
|
|
1561
|
+
|
|
1562
|
+
// 마우스가 눌려있지 않으면 드래그 불가
|
|
1563
|
+
if (!isMouseDown) {
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// selectionStartCell이 있고, 다른 셀로 이동한 경우에만 드래그 선택 모드 활성화
|
|
1568
|
+
if (selectionStartCell && cell !== selectionStartCell && !isSelectingCells) {
|
|
1569
|
+
isSelectingCells = true;
|
|
1570
|
+
e.preventDefault();
|
|
1571
|
+
e.stopPropagation();
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
if (!isSelectingCells || !selectionStartCell) return;
|
|
1575
|
+
|
|
1576
|
+
e.preventDefault();
|
|
1577
|
+
e.stopPropagation();
|
|
1578
|
+
|
|
1579
|
+
// 범위 내 모든 셀 선택
|
|
1580
|
+
const cellsInRange = getCellsInRange(selectionStartCell, cell);
|
|
1581
|
+
|
|
1582
|
+
// 기존 선택 클래스 제거
|
|
1583
|
+
const allSelectedCells = editorRef.querySelectorAll('.selected-cell');
|
|
1584
|
+
allSelectedCells.forEach(c => c.classList.remove('selected-cell'));
|
|
1585
|
+
|
|
1586
|
+
// 새 선택 적용
|
|
1587
|
+
selectedTableCells = cellsInRange;
|
|
1588
|
+
cellsInRange.forEach(c => c.classList.add('selected-cell'));
|
|
1589
|
+
};
|
|
1590
|
+
|
|
1591
|
+
// 표 셀 마우스 업 (드래그 선택 종료)
|
|
1592
|
+
const handleCellMouseUp = (e: MouseEvent) => {
|
|
1593
|
+
const target = e.target as HTMLElement;
|
|
1594
|
+
const cell = target.closest('td') as HTMLTableCellElement;
|
|
1595
|
+
|
|
1596
|
+
// 드래그 선택 중이었다면 플래그 설정
|
|
1597
|
+
if (isSelectingCells) {
|
|
1598
|
+
// 셀 내부에서 마우스 업한 경우 이벤트 방지
|
|
1599
|
+
if (cell && editorRef?.contains(cell)) {
|
|
1600
|
+
e.preventDefault();
|
|
1601
|
+
e.stopPropagation();
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// 드래그가 방금 끝났음을 표시
|
|
1605
|
+
justFinishedDragging = true;
|
|
1606
|
+
|
|
1607
|
+
// 50ms 후 플래그 해제 (클릭 이벤트가 처리된 후)
|
|
1608
|
+
setTimeout(() => {
|
|
1609
|
+
justFinishedDragging = false;
|
|
1610
|
+
}, 50);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// 마우스 다운 상태 해제
|
|
1614
|
+
isMouseDown = false;
|
|
1615
|
+
|
|
1616
|
+
// 드래그 선택 모드 종료 (선택된 셀은 유지)
|
|
1617
|
+
isSelectingCells = false;
|
|
1618
|
+
};
|
|
1619
|
+
|
|
1620
|
+
// Reset table cell background color
|
|
1621
|
+
const resetTableCellBackgroundColor = () => {
|
|
1622
|
+
const cellsToReset = selectedTableCells.length > 0 ? selectedTableCells : (selectedTableCell ? [selectedTableCell] : []);
|
|
1623
|
+
cellsToReset.forEach(cell => {
|
|
1624
|
+
cell.style.removeProperty('background-color');
|
|
1625
|
+
});
|
|
1626
|
+
isTableContextMenuOpen = false;
|
|
1627
|
+
isTableCellColorOpen = false;
|
|
1628
|
+
clearCellSelection();
|
|
1629
|
+
handleInput();
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
// Set table cell background color
|
|
1633
|
+
const setTableCellBackgroundColor = (color: string) => {
|
|
1634
|
+
const cellsToColor = selectedTableCells.length > 0 ? selectedTableCells : (selectedTableCell ? [selectedTableCell] : []);
|
|
1635
|
+
cellsToColor.forEach(cell => {
|
|
1636
|
+
cell.style.backgroundColor = color;
|
|
1637
|
+
});
|
|
1638
|
+
isTableContextMenuOpen = false;
|
|
1639
|
+
isTableCellColorOpen = false;
|
|
1640
|
+
clearCellSelection();
|
|
1641
|
+
handleInput();
|
|
1642
|
+
};
|
|
1643
|
+
|
|
1644
|
+
// Change table cell alignment
|
|
1645
|
+
const changeTableCellAlign = (align: 'left' | 'center' | 'right') => {
|
|
1646
|
+
const cellsToAlign = selectedTableCells.length > 0 ? selectedTableCells : (selectedTableCell ? [selectedTableCell] : []);
|
|
1647
|
+
cellsToAlign.forEach(cell => {
|
|
1648
|
+
cell.style.textAlign = align;
|
|
1649
|
+
});
|
|
1650
|
+
isTableContextMenuOpen = false;
|
|
1651
|
+
clearCellSelection();
|
|
1652
|
+
handleInput();
|
|
1653
|
+
};
|
|
1654
|
+
|
|
1655
|
+
// Add table row
|
|
1656
|
+
const addTableRow = (position: 'above' | 'below') => {
|
|
1657
|
+
if (!selectedTableCell) return;
|
|
1658
|
+
|
|
1659
|
+
const row = selectedTableCell.closest('tr');
|
|
1660
|
+
if (!row) return;
|
|
1661
|
+
|
|
1662
|
+
const newRow = document.createElement('tr');
|
|
1663
|
+
const colCount = row.children.length;
|
|
1664
|
+
|
|
1665
|
+
for (let i = 0; i < colCount; i++) {
|
|
1666
|
+
const td = document.createElement('td');
|
|
1667
|
+
td.style.border = '1px solid #ddd';
|
|
1668
|
+
td.style.padding = '8px';
|
|
1669
|
+
td.style.minWidth = '50px';
|
|
1670
|
+
td.innerHTML = '<br>';
|
|
1671
|
+
newRow.appendChild(td);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
if (position === 'above') {
|
|
1675
|
+
row.before(newRow);
|
|
1676
|
+
} else {
|
|
1677
|
+
row.after(newRow);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
isTableContextMenuOpen = false;
|
|
1681
|
+
clearCellSelection();
|
|
1682
|
+
handleInput();
|
|
1683
|
+
};
|
|
1684
|
+
|
|
1685
|
+
// Delete table row
|
|
1686
|
+
const deleteTableRow = () => {
|
|
1687
|
+
if (!selectedTableCell) return;
|
|
1688
|
+
|
|
1689
|
+
const row = selectedTableCell.closest('tr');
|
|
1690
|
+
if (!row) return;
|
|
1691
|
+
|
|
1692
|
+
const tbody = row.parentNode as HTMLTableSectionElement;
|
|
1693
|
+
if (!tbody) return;
|
|
1694
|
+
|
|
1695
|
+
// 마지막 행이면 삭제 불가
|
|
1696
|
+
if (tbody.rows.length <= 1) {
|
|
1697
|
+
alert('표에는 최소 1개의 행이 필요합니다.');
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
row.remove();
|
|
1702
|
+
isTableContextMenuOpen = false;
|
|
1703
|
+
selectedTableCell = null;
|
|
1704
|
+
clearCellSelection();
|
|
1705
|
+
handleInput();
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
// Add table column
|
|
1709
|
+
const addTableColumn = (position: 'left' | 'right') => {
|
|
1710
|
+
if (!selectedTableCell) return;
|
|
1711
|
+
|
|
1712
|
+
const table = selectedTableCell.closest('table');
|
|
1713
|
+
if (!table) return;
|
|
1714
|
+
|
|
1715
|
+
const row = selectedTableCell.closest('tr');
|
|
1716
|
+
if (!row) return;
|
|
1717
|
+
|
|
1718
|
+
const cellIndex = Array.from(row.children).indexOf(selectedTableCell);
|
|
1719
|
+
const rows = table.querySelectorAll('tr');
|
|
1720
|
+
|
|
1721
|
+
rows.forEach(tr => {
|
|
1722
|
+
const td = document.createElement('td');
|
|
1723
|
+
td.style.border = '1px solid #ddd';
|
|
1724
|
+
td.style.padding = '8px';
|
|
1725
|
+
td.style.minWidth = '50px';
|
|
1726
|
+
td.innerHTML = '<br>';
|
|
1727
|
+
|
|
1728
|
+
const targetCell = tr.children[cellIndex];
|
|
1729
|
+
if (position === 'left') {
|
|
1730
|
+
targetCell?.before(td);
|
|
1731
|
+
} else {
|
|
1732
|
+
targetCell?.after(td);
|
|
855
1733
|
}
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
isTableContextMenuOpen = false;
|
|
1737
|
+
clearCellSelection();
|
|
1738
|
+
handleInput();
|
|
1739
|
+
};
|
|
1740
|
+
|
|
1741
|
+
// Delete table column
|
|
1742
|
+
const deleteTableColumn = () => {
|
|
1743
|
+
if (!selectedTableCell) return;
|
|
1744
|
+
|
|
1745
|
+
const cellIndex = selectedTableCell.cellIndex;
|
|
1746
|
+
const row = selectedTableCell.closest('tr');
|
|
1747
|
+
if (!row) return;
|
|
1748
|
+
|
|
1749
|
+
const table = row.closest('table');
|
|
1750
|
+
if (!table) return;
|
|
1751
|
+
|
|
1752
|
+
const tbody = table.querySelector('tbody');
|
|
1753
|
+
if (!tbody) return;
|
|
1754
|
+
|
|
1755
|
+
// 마지막 열이면 삭제 불가
|
|
1756
|
+
if (row.cells.length <= 1) {
|
|
1757
|
+
alert('표에는 최소 1개의 열이 필요합니다.');
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
Array.from(tbody.rows).forEach(tr => {
|
|
1762
|
+
if (tr.cells[cellIndex]) {
|
|
1763
|
+
tr.cells[cellIndex].remove();
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
isTableContextMenuOpen = false;
|
|
1768
|
+
selectedTableCell = null;
|
|
1769
|
+
clearCellSelection();
|
|
1770
|
+
handleInput();
|
|
1771
|
+
};
|
|
1772
|
+
|
|
1773
|
+
// Code view toggle
|
|
1774
|
+
const toggleCodeView = () => {
|
|
1775
|
+
if (!isCodeView) {
|
|
1776
|
+
// Switch to code view
|
|
1777
|
+
if (!editorRef) return;
|
|
1778
|
+
savedEditorHeight = editorRef.scrollHeight;
|
|
1779
|
+
originalHtml = editorRef.innerHTML;
|
|
1780
|
+
|
|
1781
|
+
// Format HTML
|
|
1782
|
+
const formatted = originalHtml
|
|
1783
|
+
.replace(/></g, '>\n<')
|
|
1784
|
+
.replace(/(<\/?(?:p|div|h[1-6]|ul|ol|li|table|tr|td|th|tbody|thead|blockquote|pre|hr)[^>]*>)/gi, '\n$1\n')
|
|
1785
|
+
.split('\n')
|
|
1786
|
+
.filter(line => line.trim())
|
|
1787
|
+
.join('\n');
|
|
1788
|
+
|
|
1789
|
+
codeContent = formatted;
|
|
1790
|
+
isCodeView = true;
|
|
1791
|
+
} else {
|
|
1792
|
+
// Switch back to WYSIWYG
|
|
1793
|
+
const contentToApply = codeContent.replace(/\n/g, '');
|
|
856
1794
|
isCodeView = false;
|
|
1795
|
+
savedEditorHeight = null;
|
|
1796
|
+
|
|
1797
|
+
// Wait for next tick when editorRef is bound
|
|
1798
|
+
setTimeout(() => {
|
|
1799
|
+
if (editorRef) {
|
|
1800
|
+
editorRef.innerHTML = contentToApply;
|
|
1801
|
+
handleInput();
|
|
1802
|
+
}
|
|
1803
|
+
}, 0);
|
|
857
1804
|
}
|
|
858
1805
|
};
|
|
859
1806
|
|
|
1807
|
+
// 이미지 선택
|
|
1808
|
+
const selectImage = (img: HTMLImageElement) => {
|
|
1809
|
+
// 기존 선택 해제
|
|
1810
|
+
if (selectedImage) {
|
|
1811
|
+
deselectImage();
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
selectedImage = img;
|
|
1815
|
+
|
|
1816
|
+
// 이미지 주위에 wrapper 추가
|
|
1817
|
+
const wrapper = document.createElement('div');
|
|
1818
|
+
wrapper.className = 'image-wrapper';
|
|
1819
|
+
wrapper.style.position = 'relative';
|
|
1820
|
+
wrapper.style.display = 'inline-block';
|
|
1821
|
+
wrapper.style.border = '2px solid #0084ff';
|
|
1822
|
+
wrapper.style.padding = '0';
|
|
1823
|
+
|
|
1824
|
+
// 리사이즈 핸들 추가
|
|
1825
|
+
const handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
|
1826
|
+
handles.forEach(handle => {
|
|
1827
|
+
const handleDiv = document.createElement('div');
|
|
1828
|
+
handleDiv.className = `resize-handle resize-handle-${handle}`;
|
|
1829
|
+
handleDiv.dataset.handle = handle;
|
|
1830
|
+
handleDiv.style.position = 'absolute';
|
|
1831
|
+
handleDiv.style.width = '8px';
|
|
1832
|
+
handleDiv.style.height = '8px';
|
|
1833
|
+
handleDiv.style.backgroundColor = '#0084ff';
|
|
1834
|
+
handleDiv.style.border = '1px solid white';
|
|
1835
|
+
handleDiv.style.borderRadius = '2px';
|
|
1836
|
+
handleDiv.style.cursor = `${handle}-resize`;
|
|
1837
|
+
|
|
1838
|
+
// 핸들 위치 설정
|
|
1839
|
+
switch(handle) {
|
|
1840
|
+
case 'nw': handleDiv.style.top = '-5px'; handleDiv.style.left = '-5px'; break;
|
|
1841
|
+
case 'n': handleDiv.style.top = '-5px'; handleDiv.style.left = '50%'; handleDiv.style.transform = 'translateX(-50%)'; break;
|
|
1842
|
+
case 'ne': handleDiv.style.top = '-5px'; handleDiv.style.right = '-5px'; break;
|
|
1843
|
+
case 'e': handleDiv.style.top = '50%'; handleDiv.style.right = '-5px'; handleDiv.style.transform = 'translateY(-50%)'; break;
|
|
1844
|
+
case 'se': handleDiv.style.bottom = '-5px'; handleDiv.style.right = '-5px'; break;
|
|
1845
|
+
case 's': handleDiv.style.bottom = '-5px'; handleDiv.style.left = '50%'; handleDiv.style.transform = 'translateX(-50%)'; break;
|
|
1846
|
+
case 'sw': handleDiv.style.bottom = '-5px'; handleDiv.style.left = '-5px'; break;
|
|
1847
|
+
case 'w': handleDiv.style.top = '50%'; handleDiv.style.left = '-5px'; handleDiv.style.transform = 'translateY(-50%)'; break;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// 리사이즈 이벤트 핸들러
|
|
1851
|
+
handleDiv.onmousedown = (e) => {
|
|
1852
|
+
e.preventDefault();
|
|
1853
|
+
e.stopPropagation();
|
|
1854
|
+
startResize(e, img, handle);
|
|
1855
|
+
};
|
|
1856
|
+
|
|
1857
|
+
wrapper.appendChild(handleDiv);
|
|
1858
|
+
});
|
|
1859
|
+
|
|
1860
|
+
// 이미지를 wrapper로 감싸기
|
|
1861
|
+
const parent = img.parentNode;
|
|
1862
|
+
parent?.insertBefore(wrapper, img);
|
|
1863
|
+
wrapper.appendChild(img);
|
|
1864
|
+
|
|
1865
|
+
// 편집 팝업 데이터 설정
|
|
1866
|
+
if (img.style.width) {
|
|
1867
|
+
editImageWidth = img.style.width;
|
|
1868
|
+
} else {
|
|
1869
|
+
editImageWidth = 'original';
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// 이미지의 정렬 상태 확인
|
|
1873
|
+
let container = img.parentElement;
|
|
1874
|
+
let currentImageAlign = 'left';
|
|
1875
|
+
|
|
1876
|
+
while (container && container !== editorRef) {
|
|
1877
|
+
if (container.tagName === 'DIV' && container.style.textAlign) {
|
|
1878
|
+
currentImageAlign = container.style.textAlign;
|
|
1879
|
+
break;
|
|
1880
|
+
}
|
|
1881
|
+
container = container.parentElement;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
editImageAlign = currentImageAlign;
|
|
1885
|
+
editImageAlt = img.alt || '';
|
|
1886
|
+
|
|
1887
|
+
// 약간의 지연 후 편집창 열기
|
|
1888
|
+
setTimeout(() => {
|
|
1889
|
+
isImageEditPopupOpen = true;
|
|
1890
|
+
}, 50);
|
|
1891
|
+
};
|
|
1892
|
+
|
|
1893
|
+
// 이미지 선택 해제
|
|
1894
|
+
const deselectImage = () => {
|
|
1895
|
+
if (!selectedImage) return;
|
|
1896
|
+
|
|
1897
|
+
// wrapper 제거
|
|
1898
|
+
const wrapper = selectedImage.parentElement;
|
|
1899
|
+
if (wrapper && wrapper.classList.contains('image-wrapper')) {
|
|
1900
|
+
const parent = wrapper.parentNode;
|
|
1901
|
+
if (parent) {
|
|
1902
|
+
try {
|
|
1903
|
+
parent.insertBefore(selectedImage, wrapper);
|
|
1904
|
+
wrapper.remove();
|
|
1905
|
+
} catch (e) {
|
|
1906
|
+
// 이미 제거된 경우 무시
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// 이미지 draggable 속성 제거
|
|
1912
|
+
if (selectedImage) {
|
|
1913
|
+
selectedImage.draggable = false;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
// 상태 초기화
|
|
1917
|
+
selectedImage = null;
|
|
1918
|
+
isImageEditPopupOpen = false;
|
|
1919
|
+
isResizing = false;
|
|
1920
|
+
resizeStartData = null;
|
|
1921
|
+
};
|
|
1922
|
+
|
|
1923
|
+
// 리사이즈 시작
|
|
1924
|
+
const startResize = (e: MouseEvent, element: HTMLImageElement | HTMLElement, handle: string) => {
|
|
1925
|
+
isResizing = true;
|
|
1926
|
+
resizeStartData = {
|
|
1927
|
+
startX: e.clientX,
|
|
1928
|
+
startY: e.clientY,
|
|
1929
|
+
startWidth: element.offsetWidth,
|
|
1930
|
+
startHeight: element.offsetHeight,
|
|
1931
|
+
handle
|
|
1932
|
+
};
|
|
1933
|
+
};
|
|
1934
|
+
|
|
1935
|
+
// 이미지 편집 적용
|
|
1936
|
+
const applyImageEdit = () => {
|
|
1937
|
+
if (!selectedImage) return;
|
|
1938
|
+
|
|
1939
|
+
// 크기 적용
|
|
1940
|
+
if (editImageWidth) {
|
|
1941
|
+
if (editImageWidth.includes('%')) {
|
|
1942
|
+
selectedImage.style.width = editImageWidth;
|
|
1943
|
+
selectedImage.style.height = 'auto';
|
|
1944
|
+
} else if (editImageWidth === 'original') {
|
|
1945
|
+
selectedImage.style.width = '';
|
|
1946
|
+
selectedImage.style.height = '';
|
|
1947
|
+
} else {
|
|
1948
|
+
selectedImage.style.width = editImageWidth;
|
|
1949
|
+
selectedImage.style.height = 'auto';
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// 정렬 적용
|
|
1954
|
+
let alignContainer = selectedImage.parentElement;
|
|
1955
|
+
|
|
1956
|
+
if (alignContainer?.classList.contains('image-wrapper')) {
|
|
1957
|
+
alignContainer = alignContainer.parentElement;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
if (alignContainer && alignContainer.tagName === 'DIV' && alignContainer !== editorRef) {
|
|
1961
|
+
alignContainer.style.textAlign = editImageAlign;
|
|
1962
|
+
} else {
|
|
1963
|
+
const newContainer = document.createElement('div');
|
|
1964
|
+
newContainer.style.textAlign = editImageAlign;
|
|
1965
|
+
|
|
1966
|
+
const elementToWrap = selectedImage.parentElement?.classList.contains('image-wrapper')
|
|
1967
|
+
? selectedImage.parentElement
|
|
1968
|
+
: selectedImage;
|
|
1969
|
+
|
|
1970
|
+
if (elementToWrap.parentNode) {
|
|
1971
|
+
elementToWrap.parentNode.insertBefore(newContainer, elementToWrap);
|
|
1972
|
+
newContainer.appendChild(elementToWrap);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// 대체 텍스트 적용
|
|
1977
|
+
selectedImage.alt = editImageAlt;
|
|
1978
|
+
|
|
1979
|
+
// 선택 해제
|
|
1980
|
+
deselectImage();
|
|
1981
|
+
handleInput();
|
|
1982
|
+
};
|
|
1983
|
+
|
|
1984
|
+
// 이미지 삭제
|
|
1985
|
+
const deleteImage = () => {
|
|
1986
|
+
if (!selectedImage) return;
|
|
1987
|
+
|
|
1988
|
+
const imageToDelete = selectedImage;
|
|
1989
|
+
deselectImage();
|
|
1990
|
+
|
|
1991
|
+
let elementToRemove: HTMLElement = imageToDelete;
|
|
1992
|
+
let parent = imageToDelete.parentElement;
|
|
1993
|
+
|
|
1994
|
+
while (parent && parent !== editorRef) {
|
|
1995
|
+
if (parent.classList.contains('image-wrapper') ||
|
|
1996
|
+
(parent.tagName === 'DIV' && parent.style.textAlign)) {
|
|
1997
|
+
elementToRemove = parent;
|
|
1998
|
+
parent = parent.parentElement;
|
|
1999
|
+
} else {
|
|
2000
|
+
break;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
if (elementToRemove.parentNode) {
|
|
2005
|
+
elementToRemove.parentNode.removeChild(elementToRemove);
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
handleInput();
|
|
2009
|
+
};
|
|
2010
|
+
|
|
2011
|
+
// 유튜브 선택
|
|
2012
|
+
const selectYoutube = (youtubeContainer: HTMLElement) => {
|
|
2013
|
+
if (selectedYoutube) {
|
|
2014
|
+
deselectYoutube();
|
|
2015
|
+
}
|
|
2016
|
+
if (selectedImage) {
|
|
2017
|
+
deselectImage();
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
selectedYoutube = youtubeContainer;
|
|
2021
|
+
|
|
2022
|
+
// 유튜브 주위에 wrapper 추가
|
|
2023
|
+
const wrapper = document.createElement('div');
|
|
2024
|
+
wrapper.className = 'youtube-wrapper';
|
|
2025
|
+
wrapper.style.position = 'relative';
|
|
2026
|
+
wrapper.style.border = '2px solid #0084ff';
|
|
2027
|
+
wrapper.style.padding = '0';
|
|
2028
|
+
|
|
2029
|
+
// wrapper를 유튜브 컨테이너와 동일한 display 속성으로 설정
|
|
2030
|
+
const computedStyle = window.getComputedStyle(youtubeContainer);
|
|
2031
|
+
wrapper.style.display = computedStyle.display;
|
|
2032
|
+
// style.width가 있으면 그것을 사용, 없으면 computed width 사용
|
|
2033
|
+
wrapper.style.width = youtubeContainer.style.width || computedStyle.width;
|
|
2034
|
+
|
|
2035
|
+
// 원본 스타일 저장 (나중에 복원용)
|
|
2036
|
+
youtubeContainer.dataset.originalWidth = youtubeContainer.style.width;
|
|
2037
|
+
youtubeContainer.dataset.originalDisplay = youtubeContainer.style.display;
|
|
2038
|
+
|
|
2039
|
+
// 리사이즈 핸들 추가
|
|
2040
|
+
const handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
|
2041
|
+
handles.forEach(handle => {
|
|
2042
|
+
const handleDiv = document.createElement('div');
|
|
2043
|
+
handleDiv.className = `resize-handle resize-handle-${handle}`;
|
|
2044
|
+
handleDiv.dataset.handle = handle;
|
|
2045
|
+
handleDiv.style.position = 'absolute';
|
|
2046
|
+
handleDiv.style.width = '8px';
|
|
2047
|
+
handleDiv.style.height = '8px';
|
|
2048
|
+
handleDiv.style.backgroundColor = '#0084ff';
|
|
2049
|
+
handleDiv.style.border = '1px solid white';
|
|
2050
|
+
handleDiv.style.borderRadius = '2px';
|
|
2051
|
+
handleDiv.style.cursor = `${handle}-resize`;
|
|
2052
|
+
|
|
2053
|
+
switch(handle) {
|
|
2054
|
+
case 'nw': handleDiv.style.top = '-5px'; handleDiv.style.left = '-5px'; break;
|
|
2055
|
+
case 'n': handleDiv.style.top = '-5px'; handleDiv.style.left = '50%'; handleDiv.style.transform = 'translateX(-50%)'; break;
|
|
2056
|
+
case 'ne': handleDiv.style.top = '-5px'; handleDiv.style.right = '-5px'; break;
|
|
2057
|
+
case 'e': handleDiv.style.top = '50%'; handleDiv.style.right = '-5px'; handleDiv.style.transform = 'translateY(-50%)'; break;
|
|
2058
|
+
case 'se': handleDiv.style.bottom = '-5px'; handleDiv.style.right = '-5px'; break;
|
|
2059
|
+
case 's': handleDiv.style.bottom = '-5px'; handleDiv.style.left = '50%'; handleDiv.style.transform = 'translateX(-50%)'; break;
|
|
2060
|
+
case 'sw': handleDiv.style.bottom = '-5px'; handleDiv.style.left = '-5px'; break;
|
|
2061
|
+
case 'w': handleDiv.style.top = '50%'; handleDiv.style.left = '-5px'; handleDiv.style.transform = 'translateY(-50%)'; break;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
handleDiv.onmousedown = (e) => {
|
|
2065
|
+
e.preventDefault();
|
|
2066
|
+
e.stopPropagation();
|
|
2067
|
+
startResize(e, youtubeContainer, handle);
|
|
2068
|
+
};
|
|
2069
|
+
|
|
2070
|
+
wrapper.appendChild(handleDiv);
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
// 유튜브를 wrapper로 감싸기
|
|
2074
|
+
const parent = youtubeContainer.parentNode;
|
|
2075
|
+
parent?.insertBefore(wrapper, youtubeContainer);
|
|
2076
|
+
wrapper.appendChild(youtubeContainer);
|
|
2077
|
+
|
|
2078
|
+
// 편집 팝업 데이터 설정
|
|
2079
|
+
// 컨테이너 크기 확인
|
|
2080
|
+
if (youtubeContainer.style.width) {
|
|
2081
|
+
if (youtubeContainer.style.width === '560px') {
|
|
2082
|
+
editYoutubeWidth = 'original';
|
|
2083
|
+
} else if (youtubeContainer.style.width.includes('%')) {
|
|
2084
|
+
editYoutubeWidth = youtubeContainer.style.width;
|
|
2085
|
+
} else {
|
|
2086
|
+
// px 값을 %로 변환
|
|
2087
|
+
const parentWidth = editorRef?.offsetWidth || window.innerWidth;
|
|
2088
|
+
const containerWidth = parseInt(youtubeContainer.style.width);
|
|
2089
|
+
const percentage = Math.round((containerWidth / parentWidth) * 100);
|
|
2090
|
+
if (percentage >= 95) {
|
|
2091
|
+
editYoutubeWidth = '100%';
|
|
2092
|
+
} else if (percentage >= 70 && percentage <= 80) {
|
|
2093
|
+
editYoutubeWidth = '75%';
|
|
2094
|
+
} else if (percentage >= 45 && percentage <= 55) {
|
|
2095
|
+
editYoutubeWidth = '50%';
|
|
2096
|
+
} else {
|
|
2097
|
+
editYoutubeWidth = `${percentage}%`;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
} else {
|
|
2101
|
+
editYoutubeWidth = '100%';
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// 정렬 상태 확인
|
|
2105
|
+
let container = youtubeContainer.parentElement;
|
|
2106
|
+
let currentYoutubeAlign = 'center';
|
|
2107
|
+
while (container && container !== editorRef) {
|
|
2108
|
+
if (container.tagName === 'DIV' && container.style.textAlign) {
|
|
2109
|
+
currentYoutubeAlign = container.style.textAlign;
|
|
2110
|
+
break;
|
|
2111
|
+
}
|
|
2112
|
+
container = container.parentElement;
|
|
2113
|
+
}
|
|
2114
|
+
editYoutubeAlign = currentYoutubeAlign;
|
|
2115
|
+
|
|
2116
|
+
setTimeout(() => {
|
|
2117
|
+
isYoutubeEditPopupOpen = true;
|
|
2118
|
+
}, 50);
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
// 유튜브 선택 해제
|
|
2122
|
+
const deselectYoutube = () => {
|
|
2123
|
+
if (!selectedYoutube) return;
|
|
2124
|
+
|
|
2125
|
+
// 원본 스타일 복원
|
|
2126
|
+
if (selectedYoutube.dataset.originalWidth !== undefined) {
|
|
2127
|
+
selectedYoutube.style.width = selectedYoutube.dataset.originalWidth;
|
|
2128
|
+
delete selectedYoutube.dataset.originalWidth;
|
|
2129
|
+
}
|
|
2130
|
+
if (selectedYoutube.dataset.originalDisplay !== undefined) {
|
|
2131
|
+
selectedYoutube.style.display = selectedYoutube.dataset.originalDisplay;
|
|
2132
|
+
delete selectedYoutube.dataset.originalDisplay;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// wrapper 제거
|
|
2136
|
+
const wrapper = selectedYoutube.parentElement;
|
|
2137
|
+
if (wrapper && wrapper.classList.contains('youtube-wrapper')) {
|
|
2138
|
+
const parent = wrapper.parentNode;
|
|
2139
|
+
if (parent) {
|
|
2140
|
+
try {
|
|
2141
|
+
parent.insertBefore(selectedYoutube, wrapper);
|
|
2142
|
+
wrapper.remove();
|
|
2143
|
+
} catch (e) {
|
|
2144
|
+
// 이미 제거된 경우 무시
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// 상태 초기화
|
|
2150
|
+
selectedYoutube = null;
|
|
2151
|
+
isYoutubeEditPopupOpen = false;
|
|
2152
|
+
};
|
|
2153
|
+
|
|
2154
|
+
// 유튜브 편집 적용
|
|
2155
|
+
const applyYoutubeEdit = () => {
|
|
2156
|
+
if (!selectedYoutube) return;
|
|
2157
|
+
|
|
2158
|
+
// 크기 적용
|
|
2159
|
+
if (editYoutubeWidth === '100%' || editYoutubeWidth === '75%' || editYoutubeWidth === '50%') {
|
|
2160
|
+
// 퍼센트 값은 그대로 유지
|
|
2161
|
+
selectedYoutube.style.width = editYoutubeWidth;
|
|
2162
|
+
selectedYoutube.style.aspectRatio = '16 / 9';
|
|
2163
|
+
selectedYoutube.style.height = 'auto';
|
|
2164
|
+
} else if (editYoutubeWidth === 'original') {
|
|
2165
|
+
// original은 고정 크기
|
|
2166
|
+
selectedYoutube.style.aspectRatio = '';
|
|
2167
|
+
selectedYoutube.style.width = '560px';
|
|
2168
|
+
selectedYoutube.style.height = '315px';
|
|
2169
|
+
} else {
|
|
2170
|
+
// px 값은 그대로 설정 (리사이즈로 변경된 경우)
|
|
2171
|
+
selectedYoutube.style.aspectRatio = '';
|
|
2172
|
+
selectedYoutube.style.width = editYoutubeWidth;
|
|
2173
|
+
// height 계산
|
|
2174
|
+
const width = parseInt(editYoutubeWidth);
|
|
2175
|
+
const height = width / (16 / 9);
|
|
2176
|
+
selectedYoutube.style.height = height + 'px';
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// wrapper 크기도 업데이트
|
|
2180
|
+
const wrapper = selectedYoutube.parentElement;
|
|
2181
|
+
if (wrapper && wrapper.classList.contains('youtube-wrapper')) {
|
|
2182
|
+
wrapper.style.width = selectedYoutube.style.width;
|
|
2183
|
+
wrapper.style.aspectRatio = selectedYoutube.style.aspectRatio;
|
|
2184
|
+
if (selectedYoutube.style.height && selectedYoutube.style.height !== 'auto') {
|
|
2185
|
+
wrapper.style.height = selectedYoutube.style.height;
|
|
2186
|
+
} else {
|
|
2187
|
+
wrapper.style.height = 'auto';
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
// 정렬 적용
|
|
2192
|
+
// youtube-wrapper의 부모를 찾음
|
|
2193
|
+
const targetElement = selectedYoutube.parentElement?.classList.contains('youtube-wrapper')
|
|
2194
|
+
? selectedYoutube.parentElement
|
|
2195
|
+
: selectedYoutube;
|
|
2196
|
+
|
|
2197
|
+
// 정렬 컨테이너 찾기 (최상위 DIV 컨테이너)
|
|
2198
|
+
const alignContainer = targetElement?.parentElement;
|
|
2199
|
+
|
|
2200
|
+
// 정렬 컨테이너가 있고 DIV이면 정렬 적용
|
|
2201
|
+
if (alignContainer && alignContainer.tagName === 'DIV' && alignContainer !== editorRef) {
|
|
2202
|
+
alignContainer.style.textAlign = editYoutubeAlign;
|
|
2203
|
+
|
|
2204
|
+
// 유튜브 컨테이너 자체도 적절한 display 설정
|
|
2205
|
+
if (editYoutubeAlign === 'center' || editYoutubeAlign === 'right') {
|
|
2206
|
+
selectedYoutube.style.display = 'inline-block';
|
|
2207
|
+
} else {
|
|
2208
|
+
selectedYoutube.style.display = 'inline-block';
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
// 선택 해제
|
|
2213
|
+
deselectYoutube();
|
|
2214
|
+
handleInput();
|
|
2215
|
+
};
|
|
2216
|
+
|
|
2217
|
+
// 유튜브 삭제
|
|
2218
|
+
const deleteYoutube = () => {
|
|
2219
|
+
if (!selectedYoutube) return;
|
|
2220
|
+
|
|
2221
|
+
const youtubeToDelete = selectedYoutube;
|
|
2222
|
+
deselectYoutube();
|
|
2223
|
+
|
|
2224
|
+
let elementToRemove: HTMLElement = youtubeToDelete;
|
|
2225
|
+
let parent = youtubeToDelete.parentElement;
|
|
2226
|
+
|
|
2227
|
+
while (parent && parent !== editorRef) {
|
|
2228
|
+
if (parent.classList.contains('youtube-wrapper') ||
|
|
2229
|
+
parent.classList.contains('youtube-container') ||
|
|
2230
|
+
(parent.tagName === 'DIV' && parent.style.textAlign)) {
|
|
2231
|
+
elementToRemove = parent;
|
|
2232
|
+
parent = parent.parentElement;
|
|
2233
|
+
} else {
|
|
2234
|
+
break;
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
if (elementToRemove.parentNode) {
|
|
2239
|
+
elementToRemove.parentNode.removeChild(elementToRemove);
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
handleInput();
|
|
2243
|
+
};
|
|
2244
|
+
|
|
860
2245
|
// Keydown handler
|
|
861
2246
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
2247
|
+
// Backspace 또는 Delete 키로 선택된 이미지 삭제
|
|
2248
|
+
if ((e.key === 'Backspace' || e.key === 'Delete') && selectedImage) {
|
|
2249
|
+
e.preventDefault();
|
|
2250
|
+
deleteImage();
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// Backspace 또는 Delete 키로 선택된 유튜브 삭제
|
|
2255
|
+
if ((e.key === 'Backspace' || e.key === 'Delete') && selectedYoutube) {
|
|
2256
|
+
e.preventDefault();
|
|
2257
|
+
deleteYoutube();
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
// 에디터가 비어있고 처음 입력하는 경우
|
|
2262
|
+
if (editorRef && (!editorRef.innerHTML || editorRef.innerHTML === '<br>')) {
|
|
2263
|
+
// Enter, Backspace, Delete가 아닌 일반 문자 입력인 경우
|
|
2264
|
+
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
|
2265
|
+
e.preventDefault();
|
|
2266
|
+
|
|
2267
|
+
// p 태그 생성 및 텍스트 삽입
|
|
2268
|
+
const p = document.createElement('p');
|
|
2269
|
+
p.textContent = e.key;
|
|
2270
|
+
editorRef.innerHTML = '';
|
|
2271
|
+
editorRef.appendChild(p);
|
|
2272
|
+
|
|
2273
|
+
// 커서를 텍스트 끝으로 이동
|
|
2274
|
+
const selection = window.getSelection();
|
|
2275
|
+
const range = document.createRange();
|
|
2276
|
+
range.selectNodeContents(p);
|
|
2277
|
+
range.collapse(false);
|
|
2278
|
+
selection?.removeAllRanges();
|
|
2279
|
+
selection?.addRange(range);
|
|
2280
|
+
|
|
2281
|
+
handleInput();
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
|
|
862
2286
|
// Undo: Ctrl/Cmd + Z
|
|
863
2287
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
|
864
2288
|
e.preventDefault();
|
|
@@ -893,6 +2317,36 @@
|
|
|
893
2317
|
formatUnderline();
|
|
894
2318
|
return;
|
|
895
2319
|
}
|
|
2320
|
+
|
|
2321
|
+
// Enter 키: 새 문단 (p 태그) 생성
|
|
2322
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
2323
|
+
e.preventDefault();
|
|
2324
|
+
|
|
2325
|
+
// insertParagraph를 사용하여 새 문단 생성
|
|
2326
|
+
document.execCommand('insertParagraph', false);
|
|
2327
|
+
|
|
2328
|
+
// 새로 생성된 문단을 p 태그로 변환
|
|
2329
|
+
setTimeout(() => {
|
|
2330
|
+
const selection = window.getSelection();
|
|
2331
|
+
if (selection && selection.rangeCount > 0) {
|
|
2332
|
+
const range = selection.getRangeAt(0);
|
|
2333
|
+
let container: Node | null = range.commonAncestorContainer;
|
|
2334
|
+
|
|
2335
|
+
// 텍스트 노드인 경우 부모 요소로
|
|
2336
|
+
if (container.nodeType === Node.TEXT_NODE) {
|
|
2337
|
+
container = container.parentElement;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
// div인 경우 p로 변경
|
|
2341
|
+
if (container && (container as HTMLElement).tagName === 'DIV') {
|
|
2342
|
+
document.execCommand('formatBlock', false, 'p');
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
handleInput();
|
|
2346
|
+
}, 0);
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
// Shift+Enter는 브라우저 기본 동작 사용 (br 태그 삽입)
|
|
896
2350
|
};
|
|
897
2351
|
|
|
898
2352
|
// Click outside to close dropdowns
|
|
@@ -915,11 +2369,144 @@
|
|
|
915
2369
|
if (!target.closest(`.${styles.tableDropdown}`) && !target.closest(`.${styles.tableButton}`)) {
|
|
916
2370
|
isTableDropdownOpen = false;
|
|
917
2371
|
}
|
|
918
|
-
if (!target.closest(`.${styles.imageDropdown}`) && !target.closest(`.${styles.imageButton}`)) {
|
|
919
|
-
isImageDropdownOpen = false;
|
|
2372
|
+
if (!target.closest(`.${styles.imageDropdown}`) && !target.closest(`.${styles.imageButton}`) && !target.closest(`.${styles.youtubeButton}`)) {
|
|
2373
|
+
isImageDropdownOpen = false;
|
|
2374
|
+
isYoutubeDropdownOpen = false;
|
|
2375
|
+
}
|
|
2376
|
+
// 링크 수정 팝업 닫기
|
|
2377
|
+
if (isEditLinkPopupOpen) {
|
|
2378
|
+
const editPopup = document.querySelector(`.${styles.editLinkPopup}`);
|
|
2379
|
+
if (editPopup && !editPopup.contains(target) && !selectedLinkElement?.contains(target)) {
|
|
2380
|
+
isEditLinkPopupOpen = false;
|
|
2381
|
+
selectedLinkElement = null;
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
// 테이블 컨텍스트 메뉴 닫기
|
|
2385
|
+
if (isTableContextMenuOpen && tableContextMenuRef && !tableContextMenuRef.contains(target)) {
|
|
2386
|
+
isTableContextMenuOpen = false;
|
|
2387
|
+
selectedTableCell = null;
|
|
2388
|
+
isTableCellColorOpen = false;
|
|
2389
|
+
}
|
|
2390
|
+
// 이미지 편집 팝업 닫기
|
|
2391
|
+
if (isImageEditPopupOpen && selectedImage) {
|
|
2392
|
+
const editPopup = document.querySelector(`.${styles.imageDropdown}`);
|
|
2393
|
+
if (editPopup && !editPopup.contains(target) && !target.closest('.image-wrapper')) {
|
|
2394
|
+
deselectImage();
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
// 유튜브 편집 팝업 닫기
|
|
2398
|
+
if (isYoutubeEditPopupOpen && selectedYoutube) {
|
|
2399
|
+
const editPopup = document.querySelector(`.${styles.imageDropdown}`);
|
|
2400
|
+
if (editPopup && !editPopup.contains(target) && !target.closest('.youtube-wrapper')) {
|
|
2401
|
+
deselectYoutube();
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
};
|
|
2405
|
+
|
|
2406
|
+
// Handle editor click to detect link/image/youtube clicks
|
|
2407
|
+
const handleEditorClick = (e: MouseEvent) => {
|
|
2408
|
+
const target = e.target as HTMLElement;
|
|
2409
|
+
|
|
2410
|
+
// 리사이즈 핸들 클릭은 무시
|
|
2411
|
+
if (target.classList.contains('resize-handle')) {
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
// 이미지 편집 팝업 클릭은 무시
|
|
2416
|
+
if (target.closest(`.${styles.imageDropdown}`)) {
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
// 유튜브 편집 팝업 클릭은 무시
|
|
2421
|
+
if (target.closest(`.${styles.youtubeEditPopup}`)) {
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// 유튜브 오버레이 클릭 감지
|
|
2426
|
+
if ((target.classList.contains('youtube-overlay') || target.closest('.youtube-container')) && editorRef?.contains(target)) {
|
|
2427
|
+
e.preventDefault();
|
|
2428
|
+
e.stopPropagation();
|
|
2429
|
+
|
|
2430
|
+
const youtubeContainer = target.closest('.youtube-container') as HTMLElement;
|
|
2431
|
+
if (youtubeContainer) {
|
|
2432
|
+
if (selectedYoutube !== youtubeContainer) {
|
|
2433
|
+
if (selectedYoutube) {
|
|
2434
|
+
deselectYoutube();
|
|
2435
|
+
}
|
|
2436
|
+
if (selectedImage) {
|
|
2437
|
+
deselectImage();
|
|
2438
|
+
}
|
|
2439
|
+
selectYoutube(youtubeContainer);
|
|
2440
|
+
} else {
|
|
2441
|
+
isYoutubeEditPopupOpen = !isYoutubeEditPopupOpen;
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// 이미지 요소인지 확인
|
|
2448
|
+
if (target.tagName === 'IMG' && editorRef?.contains(target)) {
|
|
2449
|
+
e.preventDefault();
|
|
2450
|
+
e.stopPropagation();
|
|
2451
|
+
const img = target as HTMLImageElement;
|
|
2452
|
+
|
|
2453
|
+
if (selectedImage !== img) {
|
|
2454
|
+
if (selectedImage) {
|
|
2455
|
+
deselectImage();
|
|
2456
|
+
}
|
|
2457
|
+
if (selectedYoutube) {
|
|
2458
|
+
deselectYoutube();
|
|
2459
|
+
}
|
|
2460
|
+
selectImage(img);
|
|
2461
|
+
} else {
|
|
2462
|
+
isImageEditPopupOpen = !isImageEditPopupOpen;
|
|
2463
|
+
}
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// 기존 선택된 이미지가 있으면 선택 해제
|
|
2468
|
+
if (selectedImage && !target.closest('.image-wrapper') && !isResizing) {
|
|
2469
|
+
deselectImage();
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// 기존 선택된 유튜브가 있으면 선택 해제
|
|
2473
|
+
if (selectedYoutube && !target.closest('.youtube-wrapper') && !isResizing) {
|
|
2474
|
+
deselectYoutube();
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// 표 컨텍스트 메뉴 닫기
|
|
2478
|
+
if (isTableContextMenuOpen && !target.closest(`.${styles.tableContextMenu}`)) {
|
|
2479
|
+
isTableContextMenuOpen = false;
|
|
2480
|
+
selectedTableCell = null;
|
|
2481
|
+
isTableCellColorOpen = false;
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// 표 셀 클릭 시에는 선택 유지
|
|
2485
|
+
const clickedCell = target.closest('td');
|
|
2486
|
+
|
|
2487
|
+
// 드래그가 방금 끝난 경우 선택 해제하지 않음
|
|
2488
|
+
if (justFinishedDragging) {
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
// 표 셀 외부를 클릭한 경우에만 선택 해제
|
|
2493
|
+
if (!clickedCell && selectedTableCells.length > 0) {
|
|
2494
|
+
clearCellSelection();
|
|
920
2495
|
}
|
|
921
|
-
|
|
922
|
-
|
|
2496
|
+
|
|
2497
|
+
// 링크 요소인지 확인
|
|
2498
|
+
const linkElement = target.closest('a') as HTMLAnchorElement;
|
|
2499
|
+
if (linkElement && editorRef?.contains(linkElement)) {
|
|
2500
|
+
e.preventDefault();
|
|
2501
|
+
selectedLinkElement = linkElement;
|
|
2502
|
+
editLinkUrl = linkElement.href;
|
|
2503
|
+
editLinkTarget = linkElement.target || '_self';
|
|
2504
|
+
isEditLinkPopupOpen = true;
|
|
2505
|
+
} else {
|
|
2506
|
+
// 기존 상태 감지
|
|
2507
|
+
detectCurrentParagraphStyle();
|
|
2508
|
+
detectCurrentAlign();
|
|
2509
|
+
detectTextStyles();
|
|
923
2510
|
}
|
|
924
2511
|
};
|
|
925
2512
|
|
|
@@ -930,6 +2517,51 @@
|
|
|
930
2517
|
}
|
|
931
2518
|
});
|
|
932
2519
|
|
|
2520
|
+
// 표 셀 드래그 선택 이벤트 등록
|
|
2521
|
+
$effect(() => {
|
|
2522
|
+
if (!editorRef || isCodeView) return;
|
|
2523
|
+
|
|
2524
|
+
editorRef.addEventListener('mousedown', handleCellMouseDown as EventListener);
|
|
2525
|
+
document.addEventListener('mousemove', handleCellMouseMove as EventListener);
|
|
2526
|
+
document.addEventListener('mouseup', handleCellMouseUp as EventListener);
|
|
2527
|
+
|
|
2528
|
+
return () => {
|
|
2529
|
+
editorRef.removeEventListener('mousedown', handleCellMouseDown as EventListener);
|
|
2530
|
+
document.removeEventListener('mousemove', handleCellMouseMove as EventListener);
|
|
2531
|
+
document.removeEventListener('mouseup', handleCellMouseUp as EventListener);
|
|
2532
|
+
};
|
|
2533
|
+
});
|
|
2534
|
+
|
|
2535
|
+
// DOM Mutation Observer - 선택된 유튜브가 DOM에서 제거되는 것을 감지
|
|
2536
|
+
$effect(() => {
|
|
2537
|
+
if (!selectedYoutube || !editorRef) return;
|
|
2538
|
+
|
|
2539
|
+
const observer = new MutationObserver((mutations) => {
|
|
2540
|
+
mutations.forEach((mutation) => {
|
|
2541
|
+
// 제거된 노드들 확인
|
|
2542
|
+
mutation.removedNodes.forEach((node) => {
|
|
2543
|
+
// 제거된 노드가 선택된 유튜브이거나 그것을 포함하는 경우
|
|
2544
|
+
if (node === selectedYoutube ||
|
|
2545
|
+
(node.nodeType === Node.ELEMENT_NODE &&
|
|
2546
|
+
(node as Element).contains(selectedYoutube))) {
|
|
2547
|
+
// 선택 상태 해제
|
|
2548
|
+
deselectYoutube();
|
|
2549
|
+
}
|
|
2550
|
+
});
|
|
2551
|
+
});
|
|
2552
|
+
});
|
|
2553
|
+
|
|
2554
|
+
// 에디터 관찰 시작
|
|
2555
|
+
observer.observe(editorRef, {
|
|
2556
|
+
childList: true,
|
|
2557
|
+
subtree: true
|
|
2558
|
+
});
|
|
2559
|
+
|
|
2560
|
+
return () => {
|
|
2561
|
+
observer.disconnect();
|
|
2562
|
+
};
|
|
2563
|
+
});
|
|
2564
|
+
|
|
933
2565
|
onMount(() => {
|
|
934
2566
|
if (editorRef && value) {
|
|
935
2567
|
editorRef.innerHTML = value;
|
|
@@ -949,6 +2581,135 @@
|
|
|
949
2581
|
}
|
|
950
2582
|
});
|
|
951
2583
|
|
|
2584
|
+
// 리사이즈 중 마우스 이벤트 처리
|
|
2585
|
+
$effect(() => {
|
|
2586
|
+
if (!isResizing || !resizeStartData) return;
|
|
2587
|
+
|
|
2588
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
2589
|
+
if (!resizeStartData) return;
|
|
2590
|
+
|
|
2591
|
+
const deltaX = e.clientX - resizeStartData.startX;
|
|
2592
|
+
const deltaY = e.clientY - resizeStartData.startY;
|
|
2593
|
+
|
|
2594
|
+
// 이미지 리사이즈
|
|
2595
|
+
if (selectedImage) {
|
|
2596
|
+
const aspectRatio = resizeStartData.startWidth / resizeStartData.startHeight;
|
|
2597
|
+
let newWidth = resizeStartData.startWidth;
|
|
2598
|
+
let newHeight = resizeStartData.startHeight;
|
|
2599
|
+
|
|
2600
|
+
switch (resizeStartData.handle) {
|
|
2601
|
+
case 'e':
|
|
2602
|
+
case 'w':
|
|
2603
|
+
newWidth = resizeStartData.startWidth + (resizeStartData.handle === 'e' ? deltaX : -deltaX);
|
|
2604
|
+
newHeight = newWidth / aspectRatio;
|
|
2605
|
+
break;
|
|
2606
|
+
case 'n':
|
|
2607
|
+
case 's':
|
|
2608
|
+
newHeight = resizeStartData.startHeight + (resizeStartData.handle === 's' ? deltaY : -deltaY);
|
|
2609
|
+
newWidth = newHeight * aspectRatio;
|
|
2610
|
+
break;
|
|
2611
|
+
case 'ne':
|
|
2612
|
+
case 'nw':
|
|
2613
|
+
case 'se':
|
|
2614
|
+
case 'sw': {
|
|
2615
|
+
const diagonalDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
|
|
2616
|
+
const multiplier = resizeStartData.handle.includes('e') ? 1 : -1;
|
|
2617
|
+
newWidth = resizeStartData.startWidth + (diagonalDelta * multiplier);
|
|
2618
|
+
newHeight = newWidth / aspectRatio;
|
|
2619
|
+
break;
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
// 최소 크기 제한
|
|
2624
|
+
newWidth = Math.max(50, newWidth);
|
|
2625
|
+
newHeight = Math.max(50, newHeight);
|
|
2626
|
+
|
|
2627
|
+
selectedImage.style.width = newWidth + 'px';
|
|
2628
|
+
selectedImage.style.height = newHeight + 'px';
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// 유튜브 리사이즈
|
|
2632
|
+
if (selectedYoutube) {
|
|
2633
|
+
const aspectRatio = 16 / 9; // 유튜브는 16:9 고정
|
|
2634
|
+
let newWidth = resizeStartData.startWidth;
|
|
2635
|
+
let newHeight = resizeStartData.startHeight;
|
|
2636
|
+
|
|
2637
|
+
switch (resizeStartData.handle) {
|
|
2638
|
+
case 'e':
|
|
2639
|
+
case 'w':
|
|
2640
|
+
newWidth = resizeStartData.startWidth + (resizeStartData.handle === 'e' ? deltaX : -deltaX);
|
|
2641
|
+
newHeight = newWidth / aspectRatio;
|
|
2642
|
+
break;
|
|
2643
|
+
case 'n':
|
|
2644
|
+
case 's':
|
|
2645
|
+
newHeight = resizeStartData.startHeight + (resizeStartData.handle === 's' ? deltaY : -deltaY);
|
|
2646
|
+
newWidth = newHeight * aspectRatio;
|
|
2647
|
+
break;
|
|
2648
|
+
case 'ne':
|
|
2649
|
+
case 'nw':
|
|
2650
|
+
case 'se':
|
|
2651
|
+
case 'sw': {
|
|
2652
|
+
const diagonalDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
|
|
2653
|
+
const multiplier = resizeStartData.handle.includes('e') ? 1 : -1;
|
|
2654
|
+
newWidth = resizeStartData.startWidth + (diagonalDelta * multiplier);
|
|
2655
|
+
newHeight = newWidth / aspectRatio;
|
|
2656
|
+
break;
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// 최소/최대 크기 제한
|
|
2661
|
+
const parentWidth = editorRef?.offsetWidth || window.innerWidth;
|
|
2662
|
+
newWidth = Math.max(200, Math.min(newWidth, parentWidth - 40));
|
|
2663
|
+
newHeight = newWidth / aspectRatio;
|
|
2664
|
+
|
|
2665
|
+
// 유튜브 컨테이너 크기 업데이트
|
|
2666
|
+
selectedYoutube.style.aspectRatio = '';
|
|
2667
|
+
selectedYoutube.style.width = newWidth + 'px';
|
|
2668
|
+
selectedYoutube.style.height = newHeight + 'px';
|
|
2669
|
+
|
|
2670
|
+
// wrapper 크기도 업데이트
|
|
2671
|
+
const wrapper = selectedYoutube.parentElement;
|
|
2672
|
+
if (wrapper && wrapper.classList.contains('youtube-wrapper')) {
|
|
2673
|
+
wrapper.style.width = newWidth + 'px';
|
|
2674
|
+
wrapper.style.height = newHeight + 'px';
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
// 편집 중인 크기 업데이트
|
|
2678
|
+
const percentage = Math.round((newWidth / parentWidth) * 100);
|
|
2679
|
+
if (percentage >= 95) {
|
|
2680
|
+
editYoutubeWidth = '100%';
|
|
2681
|
+
} else if (percentage >= 70 && percentage <= 80) {
|
|
2682
|
+
editYoutubeWidth = '75%';
|
|
2683
|
+
} else if (percentage >= 45 && percentage <= 55) {
|
|
2684
|
+
editYoutubeWidth = '50%';
|
|
2685
|
+
} else {
|
|
2686
|
+
editYoutubeWidth = `${percentage}%`;
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
};
|
|
2690
|
+
|
|
2691
|
+
const handleMouseUp = () => {
|
|
2692
|
+
isResizing = false;
|
|
2693
|
+
resizeStartData = null;
|
|
2694
|
+
if (selectedImage) {
|
|
2695
|
+
editImageWidth = selectedImage.style.width;
|
|
2696
|
+
}
|
|
2697
|
+
if (selectedYoutube) {
|
|
2698
|
+
const currentWidth = selectedYoutube.style.width;
|
|
2699
|
+
editYoutubeWidth = currentWidth;
|
|
2700
|
+
selectedYoutube.dataset.originalWidth = currentWidth;
|
|
2701
|
+
}
|
|
2702
|
+
};
|
|
2703
|
+
|
|
2704
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
2705
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
2706
|
+
|
|
2707
|
+
return () => {
|
|
2708
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
2709
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
2710
|
+
};
|
|
2711
|
+
});
|
|
2712
|
+
|
|
952
2713
|
// Computed styles
|
|
953
2714
|
const editorStyle = $derived.by(() => {
|
|
954
2715
|
const styles: Record<string, string> = { width };
|
|
@@ -980,18 +2741,22 @@
|
|
|
980
2741
|
<button
|
|
981
2742
|
type="button"
|
|
982
2743
|
class={styles.toolbarButton}
|
|
2744
|
+
onmousedown={(e) => e.preventDefault()}
|
|
983
2745
|
onclick={performUndo}
|
|
984
2746
|
disabled={historyIndex <= 0}
|
|
985
|
-
title="실행 취소
|
|
2747
|
+
title="실행 취소"
|
|
2748
|
+
style="opacity: {historyIndex <= 0 ? 0.65 : 1}; background-color: transparent; border: none; cursor: {historyIndex <= 0 ? 'not-allowed' : 'pointer'};"
|
|
986
2749
|
>
|
|
987
2750
|
<i class={styles.undo}></i>
|
|
988
2751
|
</button>
|
|
989
2752
|
<button
|
|
990
2753
|
type="button"
|
|
991
2754
|
class={styles.toolbarButton}
|
|
2755
|
+
onmousedown={(e) => e.preventDefault()}
|
|
992
2756
|
onclick={performRedo}
|
|
993
2757
|
disabled={historyIndex >= history.length - 1}
|
|
994
|
-
title="다시 실행
|
|
2758
|
+
title="다시 실행"
|
|
2759
|
+
style="opacity: {historyIndex >= history.length - 1 ? 0.65 : 1}; background-color: transparent; border: none; cursor: {historyIndex >= history.length - 1 ? 'not-allowed' : 'pointer'};"
|
|
995
2760
|
>
|
|
996
2761
|
<i class={styles.redo}></i>
|
|
997
2762
|
</button>
|
|
@@ -1001,43 +2766,67 @@
|
|
|
1001
2766
|
<!-- Paragraph format -->
|
|
1002
2767
|
{#if isToolbarItemEnabled('paragraph')}
|
|
1003
2768
|
<div class={styles.toolbarGroup}>
|
|
1004
|
-
<
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
2769
|
+
<div bind:this={paragraphButtonRef} style="position: relative;">
|
|
2770
|
+
<button
|
|
2771
|
+
type="button"
|
|
2772
|
+
class={styles.paragraphButton}
|
|
2773
|
+
onmousedown={(e) => e.preventDefault()}
|
|
2774
|
+
onclick={() => {
|
|
2775
|
+
if (paragraphButtonRef) {
|
|
2776
|
+
const rect = paragraphButtonRef.getBoundingClientRect();
|
|
2777
|
+
paragraphDropdownPos = { top: rect.bottom, left: rect.left };
|
|
2778
|
+
}
|
|
2779
|
+
isParagraphDropdownOpen = !isParagraphDropdownOpen;
|
|
2780
|
+
isTextColorOpen = false;
|
|
2781
|
+
isBgColorOpen = false;
|
|
2782
|
+
isAlignDropdownOpen = false;
|
|
2783
|
+
}}
|
|
2784
|
+
>
|
|
2785
|
+
<span>{getCurrentStyleLabel()}</span>
|
|
2786
|
+
<i class={styles.dropdownArrow}></i>
|
|
2787
|
+
</button>
|
|
2788
|
+
{#if isParagraphDropdownOpen}
|
|
2789
|
+
<div
|
|
2790
|
+
class={styles.paragraphDropdown}
|
|
2791
|
+
style="top: {paragraphDropdownPos.top}px; left: {paragraphDropdownPos.left}px;"
|
|
2792
|
+
>
|
|
2793
|
+
{#each paragraphOptions as option}
|
|
2794
|
+
<button
|
|
2795
|
+
type="button"
|
|
2796
|
+
class="{styles.paragraphOption} {currentParagraphStyle === option.value ? styles.active : ''}"
|
|
2797
|
+
onmousedown={(e) => e.preventDefault()}
|
|
2798
|
+
onclick={() => setParagraphFormat(option.value)}
|
|
2799
|
+
>
|
|
2800
|
+
{#if option.value === 'h1'}
|
|
2801
|
+
<h1>{option.label}</h1>
|
|
2802
|
+
{:else if option.value === 'h2'}
|
|
2803
|
+
<h2>{option.label}</h2>
|
|
2804
|
+
{:else if option.value === 'h3'}
|
|
2805
|
+
<h3>{option.label}</h3>
|
|
2806
|
+
{:else}
|
|
2807
|
+
<span class={option.className || ''}>{option.label}</span>
|
|
2808
|
+
{/if}
|
|
2809
|
+
</button>
|
|
2810
|
+
{/each}
|
|
2811
|
+
</div>
|
|
2812
|
+
{/if}
|
|
2813
|
+
</div>
|
|
1025
2814
|
</div>
|
|
1026
2815
|
{/if}
|
|
1027
2816
|
|
|
1028
2817
|
<!-- Text style -->
|
|
1029
2818
|
{#if isToolbarItemEnabled('text-style')}
|
|
1030
2819
|
<div class={styles.toolbarGroup}>
|
|
1031
|
-
<button type="button" class={styles.toolbarButton} onclick={formatBold} title="굵게 (Ctrl+B)">
|
|
2820
|
+
<button type="button" class="{styles.toolbarButton} {isBold ? styles.active : ''}" onmousedown={(e) => e.preventDefault()} onclick={formatBold} title="굵게 (Ctrl+B)">
|
|
1032
2821
|
<i class={styles.bold}></i>
|
|
1033
2822
|
</button>
|
|
1034
|
-
<button type="button" class={styles.toolbarButton} onclick={formatItalic} title="기울임 (Ctrl+I)">
|
|
2823
|
+
<button type="button" class="{styles.toolbarButton} {isItalic ? styles.active : ''}" onmousedown={(e) => e.preventDefault()} onclick={formatItalic} title="기울임 (Ctrl+I)">
|
|
1035
2824
|
<i class={styles.italic}></i>
|
|
1036
2825
|
</button>
|
|
1037
|
-
<button type="button" class={styles.toolbarButton} onclick={formatUnderline} title="밑줄 (Ctrl+U)">
|
|
2826
|
+
<button type="button" class="{styles.toolbarButton} {isUnderline ? styles.active : ''}" onmousedown={(e) => e.preventDefault()} onclick={formatUnderline} title="밑줄 (Ctrl+U)">
|
|
1038
2827
|
<i class={styles.underline}></i>
|
|
1039
2828
|
</button>
|
|
1040
|
-
<button type="button" class={styles.toolbarButton} onclick={formatStrikethrough} title="취소선">
|
|
2829
|
+
<button type="button" class="{styles.toolbarButton} {isStrikethrough ? styles.active : ''}" onmousedown={(e) => e.preventDefault()} onclick={formatStrikethrough} title="취소선">
|
|
1041
2830
|
<i class={styles.strikethrough}></i>
|
|
1042
2831
|
</button>
|
|
1043
2832
|
</div>
|
|
@@ -1046,120 +2835,167 @@
|
|
|
1046
2835
|
<!-- Color -->
|
|
1047
2836
|
{#if isToolbarItemEnabled('color')}
|
|
1048
2837
|
<div class={styles.toolbarGroup}>
|
|
1049
|
-
<div
|
|
2838
|
+
<div bind:this={textColorButtonRef} style="position: relative;">
|
|
1050
2839
|
<button
|
|
1051
2840
|
type="button"
|
|
1052
2841
|
class={styles.toolbarButton}
|
|
1053
|
-
|
|
2842
|
+
onmousedown={(e) => e.preventDefault()}
|
|
2843
|
+
onclick={() => {
|
|
2844
|
+
const selection = window.getSelection();
|
|
2845
|
+
if (selection && !selection.isCollapsed) {
|
|
2846
|
+
savedSelection = selection.getRangeAt(0).cloneRange();
|
|
2847
|
+
if (textColorButtonRef) {
|
|
2848
|
+
const rect = textColorButtonRef.getBoundingClientRect();
|
|
2849
|
+
textColorDropdownPos = { top: rect.bottom, left: rect.left };
|
|
2850
|
+
}
|
|
2851
|
+
isTextColorOpen = !isTextColorOpen;
|
|
2852
|
+
isBgColorOpen = false;
|
|
2853
|
+
}
|
|
2854
|
+
}}
|
|
1054
2855
|
title="글꼴 색상"
|
|
1055
2856
|
>
|
|
1056
2857
|
<i class={styles.fontColor}></i>
|
|
1057
2858
|
</button>
|
|
1058
2859
|
{#if isTextColorOpen}
|
|
1059
|
-
<div
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
2860
|
+
<div
|
|
2861
|
+
class={styles.colorPalette}
|
|
2862
|
+
style="top: {textColorDropdownPos.top}px; left: {textColorDropdownPos.left}px;"
|
|
2863
|
+
>
|
|
2864
|
+
{#each colorPalette as row}
|
|
2865
|
+
<div class={styles.colorRow}>
|
|
2866
|
+
{#each row as color}
|
|
2867
|
+
<button
|
|
2868
|
+
type="button"
|
|
2869
|
+
class={styles.colorButton}
|
|
2870
|
+
style="background-color: {color};"
|
|
2871
|
+
onmousedown={(e) => e.preventDefault()}
|
|
2872
|
+
onclick={() => {
|
|
2873
|
+
setTextColor(color);
|
|
2874
|
+
isTextColorOpen = false;
|
|
2875
|
+
}}
|
|
2876
|
+
></button>
|
|
2877
|
+
{/each}
|
|
2878
|
+
</div>
|
|
2879
|
+
{/each}
|
|
1074
2880
|
</div>
|
|
1075
2881
|
{/if}
|
|
1076
2882
|
</div>
|
|
1077
|
-
<div
|
|
2883
|
+
<div bind:this={bgColorButtonRef} style="position: relative;">
|
|
1078
2884
|
<button
|
|
1079
2885
|
type="button"
|
|
1080
2886
|
class={styles.toolbarButton}
|
|
1081
|
-
|
|
2887
|
+
onmousedown={(e) => e.preventDefault()}
|
|
2888
|
+
onclick={() => {
|
|
2889
|
+
const selection = window.getSelection();
|
|
2890
|
+
if (selection && !selection.isCollapsed) {
|
|
2891
|
+
savedSelection = selection.getRangeAt(0).cloneRange();
|
|
2892
|
+
if (bgColorButtonRef) {
|
|
2893
|
+
const rect = bgColorButtonRef.getBoundingClientRect();
|
|
2894
|
+
bgColorDropdownPos = { top: rect.bottom, left: rect.left };
|
|
2895
|
+
}
|
|
2896
|
+
isBgColorOpen = !isBgColorOpen;
|
|
2897
|
+
isTextColorOpen = false;
|
|
2898
|
+
}
|
|
2899
|
+
}}
|
|
1082
2900
|
title="배경 색상"
|
|
1083
2901
|
>
|
|
1084
2902
|
<i class={styles.highlight}></i>
|
|
1085
2903
|
</button>
|
|
1086
2904
|
{#if isBgColorOpen}
|
|
1087
|
-
<div
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
2905
|
+
<div
|
|
2906
|
+
class={styles.colorPalette}
|
|
2907
|
+
style="top: {bgColorDropdownPos.top}px; left: {bgColorDropdownPos.left}px;"
|
|
2908
|
+
>
|
|
2909
|
+
{#each colorPalette as row}
|
|
2910
|
+
<div class={styles.colorRow}>
|
|
2911
|
+
{#each row as color}
|
|
2912
|
+
<button
|
|
2913
|
+
type="button"
|
|
2914
|
+
class={styles.colorButton}
|
|
2915
|
+
style="background-color: {color};"
|
|
2916
|
+
onmousedown={(e) => e.preventDefault()}
|
|
2917
|
+
onclick={() => {
|
|
2918
|
+
setBackgroundColor(color);
|
|
2919
|
+
isBgColorOpen = false;
|
|
2920
|
+
}}
|
|
2921
|
+
></button>
|
|
2922
|
+
{/each}
|
|
2923
|
+
</div>
|
|
2924
|
+
{/each}
|
|
1102
2925
|
</div>
|
|
1103
2926
|
{/if}
|
|
1104
2927
|
</div>
|
|
1105
2928
|
</div>
|
|
1106
2929
|
{/if}
|
|
1107
2930
|
|
|
1108
|
-
<!-- Align -->
|
|
1109
|
-
{#if isToolbarItemEnabled('align')}
|
|
2931
|
+
<!-- Align + List + Table (combined group like React) -->
|
|
2932
|
+
{#if isToolbarItemEnabled('align') || isToolbarItemEnabled('list') || isToolbarItemEnabled('table')}
|
|
1110
2933
|
<div class={styles.toolbarGroup}>
|
|
1111
|
-
|
|
2934
|
+
{#if isToolbarItemEnabled('align')}
|
|
2935
|
+
<div bind:this={alignButtonRef} style="position: relative;">
|
|
1112
2936
|
<button
|
|
1113
2937
|
type="button"
|
|
1114
2938
|
class={styles.toolbarButton}
|
|
1115
|
-
|
|
2939
|
+
onmousedown={(e) => e.preventDefault()}
|
|
2940
|
+
onclick={() => {
|
|
2941
|
+
if (alignButtonRef) {
|
|
2942
|
+
const rect = alignButtonRef.getBoundingClientRect();
|
|
2943
|
+
alignDropdownPos = { top: rect.bottom, left: rect.left };
|
|
2944
|
+
}
|
|
2945
|
+
isAlignDropdownOpen = !isAlignDropdownOpen;
|
|
2946
|
+
isParagraphDropdownOpen = false;
|
|
2947
|
+
isTextColorOpen = false;
|
|
2948
|
+
isBgColorOpen = false;
|
|
2949
|
+
}}
|
|
1116
2950
|
title={getCurrentAlignLabel()}
|
|
1117
2951
|
>
|
|
1118
|
-
<
|
|
1119
|
-
<i class={styles.dropdownArrow}></i>
|
|
2952
|
+
<i class={getCurrentAlignIcon()}></i>
|
|
1120
2953
|
</button>
|
|
1121
2954
|
{#if isAlignDropdownOpen}
|
|
1122
|
-
<div
|
|
2955
|
+
<div
|
|
2956
|
+
class={styles.alignDropdown}
|
|
2957
|
+
style="top: {alignDropdownPos.top}px; left: {alignDropdownPos.left}px;"
|
|
2958
|
+
>
|
|
1123
2959
|
{#each alignOptions as option}
|
|
1124
2960
|
<button
|
|
1125
2961
|
type="button"
|
|
1126
|
-
class={styles.alignOption}
|
|
2962
|
+
class="{styles.alignOption} {currentAlign === option.value ? styles.active : ''}"
|
|
2963
|
+
onmousedown={(e) => e.preventDefault()}
|
|
1127
2964
|
onclick={() => setAlignment(option.value)}
|
|
2965
|
+
title={option.label}
|
|
1128
2966
|
>
|
|
1129
|
-
<
|
|
1130
|
-
{option.label}
|
|
2967
|
+
<i class={styles[option.icon]}></i>
|
|
1131
2968
|
</button>
|
|
1132
2969
|
{/each}
|
|
1133
2970
|
</div>
|
|
1134
2971
|
{/if}
|
|
1135
2972
|
</div>
|
|
1136
|
-
|
|
1137
|
-
{/if}
|
|
2973
|
+
{/if}
|
|
1138
2974
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
<div class={styles.toolbarGroup}>
|
|
1142
|
-
<button type="button" class={styles.toolbarButton} onclick={() => insertList(false)} title="목록">
|
|
2975
|
+
{#if isToolbarItemEnabled('list')}
|
|
2976
|
+
<button type="button" class={styles.toolbarButton} onmousedown={(e) => e.preventDefault()} onclick={() => insertList(false)} title="목록">
|
|
1143
2977
|
<i class={styles.listUl}></i>
|
|
1144
2978
|
</button>
|
|
1145
|
-
<button type="button" class={styles.toolbarButton} onclick={() => insertList(true)} title="번호 목록">
|
|
2979
|
+
<button type="button" class={styles.toolbarButton} onmousedown={(e) => e.preventDefault()} onclick={() => insertList(true)} title="번호 목록">
|
|
1146
2980
|
<i class={styles.listOl}></i>
|
|
1147
2981
|
</button>
|
|
1148
|
-
|
|
1149
|
-
{/if}
|
|
2982
|
+
{/if}
|
|
1150
2983
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
<div class={styles.toolbarGroup}>
|
|
1154
|
-
<div class={styles.tableButton}>
|
|
2984
|
+
{#if isToolbarItemEnabled('table')}
|
|
2985
|
+
<div bind:this={tableButtonRef} class={styles.tableButton} style="position: relative;">
|
|
1155
2986
|
<button
|
|
1156
2987
|
type="button"
|
|
1157
2988
|
class={styles.toolbarButton}
|
|
2989
|
+
onmousedown={(e) => e.preventDefault()}
|
|
1158
2990
|
onclick={() => {
|
|
1159
2991
|
const selection = window.getSelection();
|
|
1160
2992
|
if (selection && selection.rangeCount > 0) {
|
|
1161
2993
|
savedTableSelection = selection.getRangeAt(0).cloneRange();
|
|
1162
2994
|
}
|
|
2995
|
+
if (tableButtonRef) {
|
|
2996
|
+
const rect = tableButtonRef.getBoundingClientRect();
|
|
2997
|
+
tableDropdownPos = { top: rect.bottom, left: rect.left };
|
|
2998
|
+
}
|
|
1163
2999
|
isTableDropdownOpen = !isTableDropdownOpen;
|
|
1164
3000
|
}}
|
|
1165
3001
|
title="표 삽입"
|
|
@@ -1167,40 +3003,52 @@
|
|
|
1167
3003
|
<i class={styles.table}></i>
|
|
1168
3004
|
</button>
|
|
1169
3005
|
{#if isTableDropdownOpen}
|
|
1170
|
-
<div class={styles.tableDropdown}>
|
|
1171
|
-
<div class={styles.
|
|
1172
|
-
{
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
3006
|
+
<div class={styles.tableDropdown} style="top: {tableDropdownPos.top}px; left: {tableDropdownPos.left}px;">
|
|
3007
|
+
<div class={styles.tableGridSelector}>
|
|
3008
|
+
<div class={styles.tableGridLabel}>
|
|
3009
|
+
{tableRows > 0 && tableCols > 0 ? `${tableRows} × ${tableCols} 표` : '표 크기 선택'}
|
|
3010
|
+
</div>
|
|
3011
|
+
<div class={styles.tableGrid}>
|
|
3012
|
+
{#each Array(10) as _, row}
|
|
3013
|
+
<div class={styles.tableGridRow}>
|
|
3014
|
+
{#each Array(10) as _, col}
|
|
3015
|
+
<div
|
|
3016
|
+
class="{styles.tableGridCell} {row < tableRows && col < tableCols ? styles.active : ''}"
|
|
3017
|
+
role="button"
|
|
3018
|
+
tabindex="0"
|
|
3019
|
+
onmouseenter={() => { tableRows = row + 1; tableCols = col + 1; }}
|
|
3020
|
+
onclick={() => insertTable(row + 1, col + 1)}
|
|
3021
|
+
></div>
|
|
3022
|
+
{/each}
|
|
3023
|
+
</div>
|
|
3024
|
+
{/each}
|
|
3025
|
+
</div>
|
|
1184
3026
|
</div>
|
|
1185
|
-
<div class={styles.tableSize}>{tableRows} x {tableCols}</div>
|
|
1186
3027
|
</div>
|
|
1187
3028
|
{/if}
|
|
1188
3029
|
</div>
|
|
3030
|
+
{/if}
|
|
1189
3031
|
</div>
|
|
1190
3032
|
{/if}
|
|
1191
3033
|
|
|
1192
|
-
<!-- Link -->
|
|
1193
|
-
{#if isToolbarItemEnabled('link')}
|
|
3034
|
+
<!-- Link + Image + YouTube (combined group like React) -->
|
|
3035
|
+
{#if isToolbarItemEnabled('link') || isToolbarItemEnabled('image') || isToolbarItemEnabled('youtube')}
|
|
1194
3036
|
<div class={styles.toolbarGroup}>
|
|
1195
|
-
|
|
3037
|
+
{#if isToolbarItemEnabled('link')}
|
|
3038
|
+
<div bind:this={linkButtonRef} class={styles.linkButton} style="position: relative;">
|
|
1196
3039
|
<button
|
|
1197
3040
|
type="button"
|
|
1198
3041
|
class={styles.toolbarButton}
|
|
3042
|
+
onmousedown={(e) => e.preventDefault()}
|
|
1199
3043
|
onclick={() => {
|
|
1200
3044
|
const selection = window.getSelection();
|
|
1201
3045
|
if (selection && selection.rangeCount > 0) {
|
|
1202
3046
|
savedSelection = selection.getRangeAt(0).cloneRange();
|
|
1203
3047
|
}
|
|
3048
|
+
if (linkButtonRef) {
|
|
3049
|
+
const rect = linkButtonRef.getBoundingClientRect();
|
|
3050
|
+
linkDropdownPos = { top: rect.bottom, left: rect.left };
|
|
3051
|
+
}
|
|
1204
3052
|
isLinkDropdownOpen = !isLinkDropdownOpen;
|
|
1205
3053
|
}}
|
|
1206
3054
|
title="링크 삽입"
|
|
@@ -1208,36 +3056,382 @@
|
|
|
1208
3056
|
<i class={styles.link}></i>
|
|
1209
3057
|
</button>
|
|
1210
3058
|
{#if isLinkDropdownOpen}
|
|
1211
|
-
<div class={styles.linkDropdown}>
|
|
3059
|
+
<div class={styles.linkDropdown} style="top: {linkDropdownPos.top}px; left: {linkDropdownPos.left}px;">
|
|
1212
3060
|
<div class={styles.linkInput}>
|
|
3061
|
+
<label>URL</label>
|
|
1213
3062
|
<input
|
|
1214
3063
|
type="text"
|
|
1215
|
-
placeholder="
|
|
3064
|
+
placeholder="https://..."
|
|
1216
3065
|
bind:value={linkUrl}
|
|
1217
3066
|
onkeydown={(e) => e.key === 'Enter' && insertLink()}
|
|
1218
3067
|
/>
|
|
1219
3068
|
</div>
|
|
1220
3069
|
<div class={styles.linkTarget}>
|
|
1221
3070
|
<label>
|
|
1222
|
-
<input type="radio" bind:group={linkTarget} value="_blank" /> 새
|
|
3071
|
+
<input type="radio" bind:group={linkTarget} value="_blank" /> 새 창에서 열기
|
|
1223
3072
|
</label>
|
|
1224
3073
|
<label>
|
|
1225
|
-
<input type="radio" bind:group={linkTarget} value="_self" /> 현재
|
|
3074
|
+
<input type="radio" bind:group={linkTarget} value="_self" /> 현재 창에서 열기
|
|
1226
3075
|
</label>
|
|
1227
3076
|
</div>
|
|
1228
|
-
<
|
|
1229
|
-
|
|
1230
|
-
|
|
3077
|
+
<div class={styles.linkActions}>
|
|
3078
|
+
<button
|
|
3079
|
+
type="button"
|
|
3080
|
+
onclick={() => {
|
|
3081
|
+
isLinkDropdownOpen = false;
|
|
3082
|
+
linkUrl = '';
|
|
3083
|
+
linkTarget = '_blank';
|
|
3084
|
+
}}
|
|
3085
|
+
class={styles.default}
|
|
3086
|
+
>
|
|
3087
|
+
취소
|
|
3088
|
+
</button>
|
|
3089
|
+
<button type="button" class={styles.primary} onclick={insertLink} disabled={!linkUrl}>
|
|
3090
|
+
삽입
|
|
3091
|
+
</button>
|
|
3092
|
+
</div>
|
|
3093
|
+
</div>
|
|
3094
|
+
{/if}
|
|
3095
|
+
</div>
|
|
3096
|
+
{/if}
|
|
3097
|
+
|
|
3098
|
+
{#if isToolbarItemEnabled('image')}
|
|
3099
|
+
<div class={styles.imageButton} bind:this={imageButtonRef} style="position: relative;">
|
|
3100
|
+
<button
|
|
3101
|
+
type="button"
|
|
3102
|
+
class={styles.toolbarButton}
|
|
3103
|
+
onmousedown={(e) => e.preventDefault()}
|
|
3104
|
+
onclick={() => {
|
|
3105
|
+
const selection = window.getSelection();
|
|
3106
|
+
if (selection && selection.rangeCount > 0) {
|
|
3107
|
+
savedImageSelection = selection.getRangeAt(0).cloneRange();
|
|
3108
|
+
}
|
|
3109
|
+
if (imageButtonRef) {
|
|
3110
|
+
const rect = imageButtonRef.getBoundingClientRect();
|
|
3111
|
+
imageDropdownPos = { top: rect.bottom, left: rect.left };
|
|
3112
|
+
}
|
|
3113
|
+
isImageDropdownOpen = !isImageDropdownOpen;
|
|
3114
|
+
isYoutubeDropdownOpen = false;
|
|
3115
|
+
}}
|
|
3116
|
+
title="이미지"
|
|
3117
|
+
>
|
|
3118
|
+
<i class={styles.image}></i>
|
|
3119
|
+
</button>
|
|
3120
|
+
{#if isImageDropdownOpen}
|
|
3121
|
+
<div class={styles.imageDropdown} style="top: {imageDropdownPos.top}px; left: {imageDropdownPos.left}px;">
|
|
3122
|
+
<div class={styles.imageTabSection}>
|
|
3123
|
+
<div class={styles.imageTabButtons}>
|
|
3124
|
+
<button
|
|
3125
|
+
type="button"
|
|
3126
|
+
class={imageTabMode === 'file' ? styles.active : ''}
|
|
3127
|
+
onclick={() => {
|
|
3128
|
+
imageTabMode = 'file';
|
|
3129
|
+
imageUrl = '';
|
|
3130
|
+
}}
|
|
3131
|
+
>
|
|
3132
|
+
파일 업로드
|
|
3133
|
+
</button>
|
|
3134
|
+
<button
|
|
3135
|
+
type="button"
|
|
3136
|
+
class={imageTabMode === 'url' ? styles.active : ''}
|
|
3137
|
+
onclick={() => {
|
|
3138
|
+
imageTabMode = 'url';
|
|
3139
|
+
imageFile = null;
|
|
3140
|
+
imagePreview = '';
|
|
3141
|
+
}}
|
|
3142
|
+
>
|
|
3143
|
+
URL 입력
|
|
3144
|
+
</button>
|
|
3145
|
+
</div>
|
|
3146
|
+
|
|
3147
|
+
<!-- 파일 업로드 탭 -->
|
|
3148
|
+
{#if imageTabMode === 'file'}
|
|
3149
|
+
<div class={styles.imageFileSection}>
|
|
3150
|
+
<input
|
|
3151
|
+
bind:this={imageFileInputRef}
|
|
3152
|
+
type="file"
|
|
3153
|
+
accept="image/*"
|
|
3154
|
+
onchange={handleImageFileSelect}
|
|
3155
|
+
style="display: none;"
|
|
3156
|
+
/>
|
|
3157
|
+
<button
|
|
3158
|
+
type="button"
|
|
3159
|
+
onclick={() => imageFileInputRef?.click()}
|
|
3160
|
+
class={styles.fileSelectButton}
|
|
3161
|
+
>
|
|
3162
|
+
{imageFile ? imageFile.name : '파일 선택'}
|
|
3163
|
+
</button>
|
|
3164
|
+
{#if imagePreview}
|
|
3165
|
+
<div class={styles.imagePreviewBox}>
|
|
3166
|
+
<img src={imagePreview} alt="Preview" />
|
|
3167
|
+
</div>
|
|
3168
|
+
{/if}
|
|
3169
|
+
</div>
|
|
3170
|
+
{/if}
|
|
3171
|
+
|
|
3172
|
+
<!-- URL 입력 탭 -->
|
|
3173
|
+
{#if imageTabMode === 'url'}
|
|
3174
|
+
<div class={styles.imageUrlSection}>
|
|
3175
|
+
<input
|
|
3176
|
+
type="text"
|
|
3177
|
+
bind:value={imageUrl}
|
|
3178
|
+
placeholder="https://..."
|
|
3179
|
+
/>
|
|
3180
|
+
</div>
|
|
3181
|
+
{/if}
|
|
3182
|
+
</div>
|
|
3183
|
+
|
|
3184
|
+
<div class={styles.imageOptions}>
|
|
3185
|
+
<div class={styles.imageOptionRow}>
|
|
3186
|
+
<label>크기</label>
|
|
3187
|
+
<div class={styles.imageSizeButtons}>
|
|
3188
|
+
<button
|
|
3189
|
+
type="button"
|
|
3190
|
+
class={imageWidth === '100%' ? styles.active : ''}
|
|
3191
|
+
onclick={() => imageWidth = '100%'}
|
|
3192
|
+
>
|
|
3193
|
+
100%
|
|
3194
|
+
</button>
|
|
3195
|
+
<button
|
|
3196
|
+
type="button"
|
|
3197
|
+
class={imageWidth === '75%' ? styles.active : ''}
|
|
3198
|
+
onclick={() => imageWidth = '75%'}
|
|
3199
|
+
>
|
|
3200
|
+
75%
|
|
3201
|
+
</button>
|
|
3202
|
+
<button
|
|
3203
|
+
type="button"
|
|
3204
|
+
class={imageWidth === '50%' ? styles.active : ''}
|
|
3205
|
+
onclick={() => imageWidth = '50%'}
|
|
3206
|
+
>
|
|
3207
|
+
50%
|
|
3208
|
+
</button>
|
|
3209
|
+
<button
|
|
3210
|
+
type="button"
|
|
3211
|
+
class={imageWidth === 'original' ? styles.active : ''}
|
|
3212
|
+
onclick={() => imageWidth = 'original'}
|
|
3213
|
+
>
|
|
3214
|
+
원본
|
|
3215
|
+
</button>
|
|
3216
|
+
</div>
|
|
3217
|
+
</div>
|
|
3218
|
+
|
|
3219
|
+
<div class={styles.imageOptionRow}>
|
|
3220
|
+
<label>정렬</label>
|
|
3221
|
+
<div class={styles.imageAlignButtons}>
|
|
3222
|
+
<button
|
|
3223
|
+
type="button"
|
|
3224
|
+
class={imageAlign === 'left' ? styles.active : ''}
|
|
3225
|
+
onclick={() => imageAlign = 'left'}
|
|
3226
|
+
title="왼쪽 정렬"
|
|
3227
|
+
>
|
|
3228
|
+
<i class={styles.alignLeft}></i>
|
|
3229
|
+
</button>
|
|
3230
|
+
<button
|
|
3231
|
+
type="button"
|
|
3232
|
+
class={imageAlign === 'center' ? styles.active : ''}
|
|
3233
|
+
onclick={() => imageAlign = 'center'}
|
|
3234
|
+
title="가운데 정렬"
|
|
3235
|
+
>
|
|
3236
|
+
<i class={styles.alignCenter}></i>
|
|
3237
|
+
</button>
|
|
3238
|
+
<button
|
|
3239
|
+
type="button"
|
|
3240
|
+
class={imageAlign === 'right' ? styles.active : ''}
|
|
3241
|
+
onclick={() => imageAlign = 'right'}
|
|
3242
|
+
title="오른쪽 정렬"
|
|
3243
|
+
>
|
|
3244
|
+
<i class={styles.alignRight}></i>
|
|
3245
|
+
</button>
|
|
3246
|
+
</div>
|
|
3247
|
+
</div>
|
|
3248
|
+
|
|
3249
|
+
<div class={styles.imageOptionRow}>
|
|
3250
|
+
<label>대체 텍스트</label>
|
|
3251
|
+
<input
|
|
3252
|
+
type="text"
|
|
3253
|
+
bind:value={imageAlt}
|
|
3254
|
+
placeholder="이미지 설명..."
|
|
3255
|
+
/>
|
|
3256
|
+
</div>
|
|
3257
|
+
</div>
|
|
3258
|
+
|
|
3259
|
+
<div class={styles.imageActions}>
|
|
3260
|
+
<button
|
|
3261
|
+
type="button"
|
|
3262
|
+
onclick={() => {
|
|
3263
|
+
isImageDropdownOpen = false;
|
|
3264
|
+
imageTabMode = 'file';
|
|
3265
|
+
imageUrl = '';
|
|
3266
|
+
imageFile = null;
|
|
3267
|
+
imagePreview = '';
|
|
3268
|
+
imageWidth = 'original';
|
|
3269
|
+
imageAlign = 'left';
|
|
3270
|
+
imageAlt = '';
|
|
3271
|
+
savedImageSelection = null;
|
|
3272
|
+
}}
|
|
3273
|
+
class={styles.default}
|
|
3274
|
+
>
|
|
3275
|
+
취소
|
|
3276
|
+
</button>
|
|
3277
|
+
<button
|
|
3278
|
+
type="button"
|
|
3279
|
+
onclick={insertImage}
|
|
3280
|
+
disabled={!imageUrl && !imageFile}
|
|
3281
|
+
class={styles.primary}
|
|
3282
|
+
>
|
|
3283
|
+
삽입
|
|
3284
|
+
</button>
|
|
3285
|
+
</div>
|
|
3286
|
+
</div>
|
|
3287
|
+
{/if}
|
|
3288
|
+
</div>
|
|
3289
|
+
{/if}
|
|
3290
|
+
|
|
3291
|
+
{#if isToolbarItemEnabled('youtube')}
|
|
3292
|
+
<div class={styles.youtubeButton} bind:this={youtubeButtonRef} style="position: relative;">
|
|
3293
|
+
<button
|
|
3294
|
+
type="button"
|
|
3295
|
+
class={styles.toolbarButton}
|
|
3296
|
+
onmousedown={(e) => e.preventDefault()}
|
|
3297
|
+
onclick={() => {
|
|
3298
|
+
const selection = window.getSelection();
|
|
3299
|
+
if (selection && selection.rangeCount > 0) {
|
|
3300
|
+
savedYoutubeSelection = selection.getRangeAt(0).cloneRange();
|
|
3301
|
+
}
|
|
3302
|
+
if (youtubeButtonRef) {
|
|
3303
|
+
const rect = youtubeButtonRef.getBoundingClientRect();
|
|
3304
|
+
youtubeDropdownPos = { top: rect.bottom, left: rect.left };
|
|
3305
|
+
}
|
|
3306
|
+
isYoutubeDropdownOpen = !isYoutubeDropdownOpen;
|
|
3307
|
+
isImageDropdownOpen = false;
|
|
3308
|
+
}}
|
|
3309
|
+
title="유튜브"
|
|
3310
|
+
>
|
|
3311
|
+
<i class={styles.youtube}></i>
|
|
3312
|
+
</button>
|
|
3313
|
+
{#if isYoutubeDropdownOpen}
|
|
3314
|
+
<div class={styles.imageDropdown} style="top: {youtubeDropdownPos.top}px; left: {youtubeDropdownPos.left}px;">
|
|
3315
|
+
<div class={styles.imageTabSection}>
|
|
3316
|
+
<div class={styles.imageTabButtons}>
|
|
3317
|
+
<button
|
|
3318
|
+
type="button"
|
|
3319
|
+
class={styles.active}
|
|
3320
|
+
style="width: 100%"
|
|
3321
|
+
>
|
|
3322
|
+
유튜브 URL
|
|
3323
|
+
</button>
|
|
3324
|
+
</div>
|
|
3325
|
+
|
|
3326
|
+
<div class={styles.imageUrlSection}>
|
|
3327
|
+
<input
|
|
3328
|
+
type="text"
|
|
3329
|
+
bind:value={youtubeUrl}
|
|
3330
|
+
placeholder="https://www.youtube.com/watch?v=... 또는 https://youtu.be/..."
|
|
3331
|
+
/>
|
|
3332
|
+
</div>
|
|
3333
|
+
</div>
|
|
3334
|
+
|
|
3335
|
+
<div class={styles.imageOptions}>
|
|
3336
|
+
<div class={styles.imageOptionRow}>
|
|
3337
|
+
<label>크기</label>
|
|
3338
|
+
<div class={styles.imageSizeButtons}>
|
|
3339
|
+
<button
|
|
3340
|
+
type="button"
|
|
3341
|
+
class={youtubeWidth === '100%' ? styles.active : ''}
|
|
3342
|
+
onclick={() => youtubeWidth = '100%'}
|
|
3343
|
+
>
|
|
3344
|
+
100%
|
|
3345
|
+
</button>
|
|
3346
|
+
<button
|
|
3347
|
+
type="button"
|
|
3348
|
+
class={youtubeWidth === '75%' ? styles.active : ''}
|
|
3349
|
+
onclick={() => youtubeWidth = '75%'}
|
|
3350
|
+
>
|
|
3351
|
+
75%
|
|
3352
|
+
</button>
|
|
3353
|
+
<button
|
|
3354
|
+
type="button"
|
|
3355
|
+
class={youtubeWidth === '50%' ? styles.active : ''}
|
|
3356
|
+
onclick={() => youtubeWidth = '50%'}
|
|
3357
|
+
>
|
|
3358
|
+
50%
|
|
3359
|
+
</button>
|
|
3360
|
+
<button
|
|
3361
|
+
type="button"
|
|
3362
|
+
class={youtubeWidth === 'original' ? styles.active : ''}
|
|
3363
|
+
onclick={() => youtubeWidth = 'original'}
|
|
3364
|
+
>
|
|
3365
|
+
원본
|
|
3366
|
+
</button>
|
|
3367
|
+
</div>
|
|
3368
|
+
</div>
|
|
3369
|
+
|
|
3370
|
+
<div class={styles.imageOptionRow}>
|
|
3371
|
+
<label>정렬</label>
|
|
3372
|
+
<div class={styles.imageAlignButtons}>
|
|
3373
|
+
<button
|
|
3374
|
+
type="button"
|
|
3375
|
+
class={youtubeAlign === 'left' ? styles.active : ''}
|
|
3376
|
+
onclick={() => youtubeAlign = 'left'}
|
|
3377
|
+
title="왼쪽 정렬"
|
|
3378
|
+
>
|
|
3379
|
+
<i class={styles.alignLeft}></i>
|
|
3380
|
+
</button>
|
|
3381
|
+
<button
|
|
3382
|
+
type="button"
|
|
3383
|
+
class={youtubeAlign === 'center' ? styles.active : ''}
|
|
3384
|
+
onclick={() => youtubeAlign = 'center'}
|
|
3385
|
+
title="가운데 정렬"
|
|
3386
|
+
>
|
|
3387
|
+
<i class={styles.alignCenter}></i>
|
|
3388
|
+
</button>
|
|
3389
|
+
<button
|
|
3390
|
+
type="button"
|
|
3391
|
+
class={youtubeAlign === 'right' ? styles.active : ''}
|
|
3392
|
+
onclick={() => youtubeAlign = 'right'}
|
|
3393
|
+
title="오른쪽 정렬"
|
|
3394
|
+
>
|
|
3395
|
+
<i class={styles.alignRight}></i>
|
|
3396
|
+
</button>
|
|
3397
|
+
</div>
|
|
3398
|
+
</div>
|
|
3399
|
+
</div>
|
|
3400
|
+
|
|
3401
|
+
<div class={styles.imageActions}>
|
|
3402
|
+
<button
|
|
3403
|
+
type="button"
|
|
3404
|
+
class={styles.default}
|
|
3405
|
+
onclick={() => {
|
|
3406
|
+
isYoutubeDropdownOpen = false;
|
|
3407
|
+
youtubeUrl = '';
|
|
3408
|
+
youtubeWidth = '100%';
|
|
3409
|
+
youtubeAlign = 'center';
|
|
3410
|
+
savedYoutubeSelection = null;
|
|
3411
|
+
}}
|
|
3412
|
+
>
|
|
3413
|
+
취소
|
|
3414
|
+
</button>
|
|
3415
|
+
<button
|
|
3416
|
+
type="button"
|
|
3417
|
+
class={styles.primary}
|
|
3418
|
+
onclick={insertYoutube}
|
|
3419
|
+
disabled={!youtubeUrl}
|
|
3420
|
+
>
|
|
3421
|
+
삽입
|
|
3422
|
+
</button>
|
|
3423
|
+
</div>
|
|
1231
3424
|
</div>
|
|
1232
3425
|
{/if}
|
|
1233
3426
|
</div>
|
|
3427
|
+
{/if}
|
|
1234
3428
|
</div>
|
|
1235
3429
|
{/if}
|
|
1236
3430
|
|
|
1237
3431
|
<!-- HR -->
|
|
1238
3432
|
{#if isToolbarItemEnabled('hr')}
|
|
1239
3433
|
<div class={styles.toolbarGroup}>
|
|
1240
|
-
<button type="button" class={styles.toolbarButton} onclick={insertHR} title="구분선">
|
|
3434
|
+
<button type="button" class={styles.toolbarButton} onmousedown={(e) => e.preventDefault()} onclick={insertHR} title="구분선">
|
|
1241
3435
|
<i class={styles.hr}></i>
|
|
1242
3436
|
</button>
|
|
1243
3437
|
</div>
|
|
@@ -1246,7 +3440,7 @@
|
|
|
1246
3440
|
<!-- Format clear -->
|
|
1247
3441
|
{#if isToolbarItemEnabled('format')}
|
|
1248
3442
|
<div class={styles.toolbarGroup}>
|
|
1249
|
-
<button type="button" class={styles.toolbarButton} onclick={removeFormat} title="서식 지우기">
|
|
3443
|
+
<button type="button" class={styles.toolbarButton} onmousedown={(e) => e.preventDefault()} onclick={removeFormat} title="서식 지우기">
|
|
1250
3444
|
<i class={styles.eraser}></i>
|
|
1251
3445
|
</button>
|
|
1252
3446
|
</div>
|
|
@@ -1258,6 +3452,7 @@
|
|
|
1258
3452
|
<button
|
|
1259
3453
|
type="button"
|
|
1260
3454
|
class="{styles.toolbarButton} {isCodeView ? styles.active : ''}"
|
|
3455
|
+
onmousedown={(e) => e.preventDefault()}
|
|
1261
3456
|
onclick={toggleCodeView}
|
|
1262
3457
|
title="코드 보기"
|
|
1263
3458
|
>
|
|
@@ -1268,7 +3463,7 @@
|
|
|
1268
3463
|
</div>
|
|
1269
3464
|
|
|
1270
3465
|
<!-- Editor content -->
|
|
1271
|
-
<div class={styles.
|
|
3466
|
+
<div class="{styles.editorContainer} {resizable ? styles.resizable : ''}" style="{editorStyle}; display: flex; flex-direction: column;">
|
|
1272
3467
|
{#if !isCodeView}
|
|
1273
3468
|
<div
|
|
1274
3469
|
bind:this={editorRef}
|
|
@@ -1283,9 +3478,16 @@
|
|
|
1283
3478
|
ondragover={handleDragOver}
|
|
1284
3479
|
ondrop={handleDrop}
|
|
1285
3480
|
onkeydown={handleKeyDown}
|
|
3481
|
+
onkeyup={() => {
|
|
3482
|
+
detectCurrentParagraphStyle();
|
|
3483
|
+
detectCurrentAlign();
|
|
3484
|
+
detectTextStyles();
|
|
3485
|
+
}}
|
|
3486
|
+
onclick={handleEditorClick}
|
|
3487
|
+
oncontextmenu={handleTableContextMenu}
|
|
1286
3488
|
role="textbox"
|
|
1287
3489
|
aria-multiline="true"
|
|
1288
|
-
style=
|
|
3490
|
+
style="flex: 1; overflow-y: auto;"
|
|
1289
3491
|
></div>
|
|
1290
3492
|
{:else}
|
|
1291
3493
|
<textarea
|
|
@@ -1297,6 +3499,408 @@
|
|
|
1297
3499
|
{/if}
|
|
1298
3500
|
</div>
|
|
1299
3501
|
|
|
3502
|
+
<!-- 링크 수정 팝업 -->
|
|
3503
|
+
{#if isEditLinkPopupOpen && selectedLinkElement}
|
|
3504
|
+
<div
|
|
3505
|
+
class={styles.editLinkPopup}
|
|
3506
|
+
style="position: absolute; top: {selectedLinkElement.offsetTop + selectedLinkElement.offsetHeight + 5}px; left: {selectedLinkElement.offsetLeft}px;"
|
|
3507
|
+
>
|
|
3508
|
+
<div class={styles.editLinkContent}>
|
|
3509
|
+
<div class={styles.editLinkInput}>
|
|
3510
|
+
<label>URL 수정</label>
|
|
3511
|
+
<input
|
|
3512
|
+
type="text"
|
|
3513
|
+
bind:value={editLinkUrl}
|
|
3514
|
+
placeholder="https://..."
|
|
3515
|
+
/>
|
|
3516
|
+
</div>
|
|
3517
|
+
<div class={styles.editLinkTarget}>
|
|
3518
|
+
<label>
|
|
3519
|
+
<input
|
|
3520
|
+
type="radio"
|
|
3521
|
+
value="_blank"
|
|
3522
|
+
checked={editLinkTarget === '_blank'}
|
|
3523
|
+
onchange={() => editLinkTarget = '_blank'}
|
|
3524
|
+
/>
|
|
3525
|
+
새 창에서 열기
|
|
3526
|
+
</label>
|
|
3527
|
+
<label>
|
|
3528
|
+
<input
|
|
3529
|
+
type="radio"
|
|
3530
|
+
value="_self"
|
|
3531
|
+
checked={editLinkTarget === '_self'}
|
|
3532
|
+
onchange={() => editLinkTarget = '_self'}
|
|
3533
|
+
/>
|
|
3534
|
+
현재 창에서 열기
|
|
3535
|
+
</label>
|
|
3536
|
+
</div>
|
|
3537
|
+
<div class={styles.editLinkActions}>
|
|
3538
|
+
<button
|
|
3539
|
+
type="button"
|
|
3540
|
+
onclick={removeLink}
|
|
3541
|
+
class={styles.danger}
|
|
3542
|
+
>
|
|
3543
|
+
링크 삭제
|
|
3544
|
+
</button>
|
|
3545
|
+
<div style="display: flex; gap: 8px;">
|
|
3546
|
+
<button
|
|
3547
|
+
type="button"
|
|
3548
|
+
onclick={() => {
|
|
3549
|
+
isEditLinkPopupOpen = false;
|
|
3550
|
+
selectedLinkElement = null;
|
|
3551
|
+
editLinkUrl = '';
|
|
3552
|
+
editLinkTarget = '_self';
|
|
3553
|
+
}}
|
|
3554
|
+
class={styles.default}
|
|
3555
|
+
>
|
|
3556
|
+
취소
|
|
3557
|
+
</button>
|
|
3558
|
+
<button
|
|
3559
|
+
type="button"
|
|
3560
|
+
onclick={updateLink}
|
|
3561
|
+
disabled={!editLinkUrl}
|
|
3562
|
+
class={styles.primary}
|
|
3563
|
+
>
|
|
3564
|
+
적용
|
|
3565
|
+
</button>
|
|
3566
|
+
</div>
|
|
3567
|
+
</div>
|
|
3568
|
+
</div>
|
|
3569
|
+
</div>
|
|
3570
|
+
{/if}
|
|
3571
|
+
|
|
3572
|
+
<!-- 테이블 컨텍스트 메뉴 -->
|
|
3573
|
+
{#if isTableContextMenuOpen && selectedTableCell}
|
|
3574
|
+
<div
|
|
3575
|
+
bind:this={tableContextMenuRef}
|
|
3576
|
+
class={styles.tableContextMenu}
|
|
3577
|
+
style="position: fixed; top: {tableContextMenuPosition.y}px; left: {tableContextMenuPosition.x}px; z-index: 10000;"
|
|
3578
|
+
>
|
|
3579
|
+
{#if selectedTableCells.length > 1}
|
|
3580
|
+
<div class={styles.tableContextMenuHeader}>
|
|
3581
|
+
{selectedTableCells.length}개 셀 선택됨
|
|
3582
|
+
</div>
|
|
3583
|
+
{/if}
|
|
3584
|
+
|
|
3585
|
+
<div class={styles.tableContextMenuItem}>
|
|
3586
|
+
<button
|
|
3587
|
+
type="button"
|
|
3588
|
+
onclick={() => isTableCellColorOpen = !isTableCellColorOpen}
|
|
3589
|
+
class={styles.tableContextMenuButton}
|
|
3590
|
+
>
|
|
3591
|
+
셀 배경색 {selectedTableCells.length > 1 ? `(${selectedTableCells.length}개)` : ''}
|
|
3592
|
+
</button>
|
|
3593
|
+
{#if isTableCellColorOpen}
|
|
3594
|
+
<div class={styles.tableCellColorPicker}>
|
|
3595
|
+
{#each colorPalette as row}
|
|
3596
|
+
<div class={styles.colorRow}>
|
|
3597
|
+
{#each row as color}
|
|
3598
|
+
<button
|
|
3599
|
+
type="button"
|
|
3600
|
+
class={styles.colorCell}
|
|
3601
|
+
style="background-color: {color};"
|
|
3602
|
+
onclick={() => setTableCellBackgroundColor(color)}
|
|
3603
|
+
title={color}
|
|
3604
|
+
></button>
|
|
3605
|
+
{/each}
|
|
3606
|
+
</div>
|
|
3607
|
+
{/each}
|
|
3608
|
+
</div>
|
|
3609
|
+
{/if}
|
|
3610
|
+
</div>
|
|
3611
|
+
|
|
3612
|
+
<button
|
|
3613
|
+
type="button"
|
|
3614
|
+
onclick={resetTableCellBackgroundColor}
|
|
3615
|
+
class={styles.tableContextMenuButton}
|
|
3616
|
+
>
|
|
3617
|
+
배경색 초기화
|
|
3618
|
+
</button>
|
|
3619
|
+
|
|
3620
|
+
<div class={styles.tableContextMenuDivider}></div>
|
|
3621
|
+
|
|
3622
|
+
<button
|
|
3623
|
+
type="button"
|
|
3624
|
+
onclick={() => changeTableCellAlign('left')}
|
|
3625
|
+
class={styles.tableContextMenuButton}
|
|
3626
|
+
>
|
|
3627
|
+
왼쪽 정렬
|
|
3628
|
+
</button>
|
|
3629
|
+
<button
|
|
3630
|
+
type="button"
|
|
3631
|
+
onclick={() => changeTableCellAlign('center')}
|
|
3632
|
+
class={styles.tableContextMenuButton}
|
|
3633
|
+
>
|
|
3634
|
+
가운데 정렬
|
|
3635
|
+
</button>
|
|
3636
|
+
<button
|
|
3637
|
+
type="button"
|
|
3638
|
+
onclick={() => changeTableCellAlign('right')}
|
|
3639
|
+
class={styles.tableContextMenuButton}
|
|
3640
|
+
>
|
|
3641
|
+
오른쪽 정렬
|
|
3642
|
+
</button>
|
|
3643
|
+
|
|
3644
|
+
<div class={styles.tableContextMenuDivider}></div>
|
|
3645
|
+
|
|
3646
|
+
<button
|
|
3647
|
+
type="button"
|
|
3648
|
+
onclick={() => addTableRow('above')}
|
|
3649
|
+
class={styles.tableContextMenuButton}
|
|
3650
|
+
>
|
|
3651
|
+
위에 행 추가
|
|
3652
|
+
</button>
|
|
3653
|
+
<button
|
|
3654
|
+
type="button"
|
|
3655
|
+
onclick={() => addTableRow('below')}
|
|
3656
|
+
class={styles.tableContextMenuButton}
|
|
3657
|
+
>
|
|
3658
|
+
아래에 행 추가
|
|
3659
|
+
</button>
|
|
3660
|
+
<button
|
|
3661
|
+
type="button"
|
|
3662
|
+
onclick={deleteTableRow}
|
|
3663
|
+
class={styles.tableContextMenuButton}
|
|
3664
|
+
>
|
|
3665
|
+
행 삭제
|
|
3666
|
+
</button>
|
|
3667
|
+
|
|
3668
|
+
<div class={styles.tableContextMenuDivider}></div>
|
|
3669
|
+
|
|
3670
|
+
<button
|
|
3671
|
+
type="button"
|
|
3672
|
+
onclick={() => addTableColumn('left')}
|
|
3673
|
+
class={styles.tableContextMenuButton}
|
|
3674
|
+
>
|
|
3675
|
+
왼쪽에 열 추가
|
|
3676
|
+
</button>
|
|
3677
|
+
<button
|
|
3678
|
+
type="button"
|
|
3679
|
+
onclick={() => addTableColumn('right')}
|
|
3680
|
+
class={styles.tableContextMenuButton}
|
|
3681
|
+
>
|
|
3682
|
+
오른쪽에 열 추가
|
|
3683
|
+
</button>
|
|
3684
|
+
<button
|
|
3685
|
+
type="button"
|
|
3686
|
+
onclick={deleteTableColumn}
|
|
3687
|
+
class={styles.tableContextMenuButton}
|
|
3688
|
+
>
|
|
3689
|
+
열 삭제
|
|
3690
|
+
</button>
|
|
3691
|
+
</div>
|
|
3692
|
+
{/if}
|
|
3693
|
+
|
|
3694
|
+
<!-- 이미지 편집 팝업 -->
|
|
3695
|
+
{#if isImageEditPopupOpen && selectedImage}
|
|
3696
|
+
{@const imageWrapper = selectedImage.parentElement?.classList.contains('image-wrapper') ? selectedImage.parentElement : selectedImage}
|
|
3697
|
+
<div
|
|
3698
|
+
class={styles.imageDropdown}
|
|
3699
|
+
style="position: fixed; top: {imageWrapper.getBoundingClientRect().bottom + 10}px; left: {Math.max(10, Math.min(imageWrapper.getBoundingClientRect().left + (imageWrapper.getBoundingClientRect().width / 2) - 180, window.innerWidth - 370))}px; z-index: 10000; min-width: 360px; max-width: 90%;"
|
|
3700
|
+
>
|
|
3701
|
+
<h3 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600;">이미지 편집</h3>
|
|
3702
|
+
<div class={styles.imageOptions} style="margin-bottom: 0;">
|
|
3703
|
+
<div class={styles.imageOptionRow}>
|
|
3704
|
+
<label>크기</label>
|
|
3705
|
+
<div class={styles.imageSizeButtons}>
|
|
3706
|
+
<button
|
|
3707
|
+
type="button"
|
|
3708
|
+
class={editImageWidth === '100%' ? styles.active : ''}
|
|
3709
|
+
onclick={() => editImageWidth = '100%'}
|
|
3710
|
+
>
|
|
3711
|
+
100%
|
|
3712
|
+
</button>
|
|
3713
|
+
<button
|
|
3714
|
+
type="button"
|
|
3715
|
+
class={editImageWidth === '75%' ? styles.active : ''}
|
|
3716
|
+
onclick={() => editImageWidth = '75%'}
|
|
3717
|
+
>
|
|
3718
|
+
75%
|
|
3719
|
+
</button>
|
|
3720
|
+
<button
|
|
3721
|
+
type="button"
|
|
3722
|
+
class={editImageWidth === '50%' ? styles.active : ''}
|
|
3723
|
+
onclick={() => editImageWidth = '50%'}
|
|
3724
|
+
>
|
|
3725
|
+
50%
|
|
3726
|
+
</button>
|
|
3727
|
+
<button
|
|
3728
|
+
type="button"
|
|
3729
|
+
class={editImageWidth === 'original' ? styles.active : ''}
|
|
3730
|
+
onclick={() => editImageWidth = 'original'}
|
|
3731
|
+
>
|
|
3732
|
+
원본
|
|
3733
|
+
</button>
|
|
3734
|
+
</div>
|
|
3735
|
+
</div>
|
|
3736
|
+
|
|
3737
|
+
<div class={styles.imageOptionRow}>
|
|
3738
|
+
<label>정렬</label>
|
|
3739
|
+
<div class={styles.imageAlignButtons}>
|
|
3740
|
+
<button
|
|
3741
|
+
type="button"
|
|
3742
|
+
class={editImageAlign === 'left' ? styles.active : ''}
|
|
3743
|
+
onclick={() => editImageAlign = 'left'}
|
|
3744
|
+
title="왼쪽 정렬"
|
|
3745
|
+
>
|
|
3746
|
+
<i class={styles.alignLeft}></i>
|
|
3747
|
+
</button>
|
|
3748
|
+
<button
|
|
3749
|
+
type="button"
|
|
3750
|
+
class={editImageAlign === 'center' ? styles.active : ''}
|
|
3751
|
+
onclick={() => editImageAlign = 'center'}
|
|
3752
|
+
title="가운데 정렬"
|
|
3753
|
+
>
|
|
3754
|
+
<i class={styles.alignCenter}></i>
|
|
3755
|
+
</button>
|
|
3756
|
+
<button
|
|
3757
|
+
type="button"
|
|
3758
|
+
class={editImageAlign === 'right' ? styles.active : ''}
|
|
3759
|
+
onclick={() => editImageAlign = 'right'}
|
|
3760
|
+
title="오른쪽 정렬"
|
|
3761
|
+
>
|
|
3762
|
+
<i class={styles.alignRight}></i>
|
|
3763
|
+
</button>
|
|
3764
|
+
</div>
|
|
3765
|
+
</div>
|
|
3766
|
+
|
|
3767
|
+
<div class={styles.imageOptionRow}>
|
|
3768
|
+
<label>대체 텍스트</label>
|
|
3769
|
+
<input
|
|
3770
|
+
type="text"
|
|
3771
|
+
bind:value={editImageAlt}
|
|
3772
|
+
placeholder="이미지 설명..."
|
|
3773
|
+
/>
|
|
3774
|
+
</div>
|
|
3775
|
+
</div>
|
|
3776
|
+
|
|
3777
|
+
<div class={styles.imageActions}>
|
|
3778
|
+
<button
|
|
3779
|
+
type="button"
|
|
3780
|
+
onclick={deleteImage}
|
|
3781
|
+
class={styles.danger}
|
|
3782
|
+
>
|
|
3783
|
+
삭제
|
|
3784
|
+
</button>
|
|
3785
|
+
<div style="display: flex; gap: 8px;">
|
|
3786
|
+
<button
|
|
3787
|
+
type="button"
|
|
3788
|
+
onclick={() => {
|
|
3789
|
+
deselectImage();
|
|
3790
|
+
}}
|
|
3791
|
+
class={styles.default}
|
|
3792
|
+
>
|
|
3793
|
+
취소
|
|
3794
|
+
</button>
|
|
3795
|
+
<button
|
|
3796
|
+
type="button"
|
|
3797
|
+
onclick={applyImageEdit}
|
|
3798
|
+
class={styles.primary}
|
|
3799
|
+
>
|
|
3800
|
+
적용
|
|
3801
|
+
</button>
|
|
3802
|
+
</div>
|
|
3803
|
+
</div>
|
|
3804
|
+
</div>
|
|
3805
|
+
{/if}
|
|
3806
|
+
|
|
3807
|
+
<!-- 유튜브 편집 팝업 -->
|
|
3808
|
+
{#if isYoutubeEditPopupOpen && selectedYoutube}
|
|
3809
|
+
{@const youtubeWrapper = selectedYoutube.parentElement?.classList.contains('youtube-wrapper') ? selectedYoutube.parentElement : selectedYoutube}
|
|
3810
|
+
<div
|
|
3811
|
+
class={styles.imageDropdown}
|
|
3812
|
+
style="position: fixed; top: {youtubeWrapper.getBoundingClientRect().bottom + 10}px; left: {Math.max(10, Math.min(youtubeWrapper.getBoundingClientRect().left + (youtubeWrapper.getBoundingClientRect().width / 2) - 180, window.innerWidth - 370))}px; z-index: 10000; min-width: 360px; max-width: 90%;"
|
|
3813
|
+
>
|
|
3814
|
+
<h3 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600;">유튜브 편집</h3>
|
|
3815
|
+
<div class={styles.imageOptions} style="margin-bottom: 0;">
|
|
3816
|
+
<div class={styles.imageOptionRow}>
|
|
3817
|
+
<label>크기</label>
|
|
3818
|
+
<div class={styles.imageSizeButtons}>
|
|
3819
|
+
<button
|
|
3820
|
+
type="button"
|
|
3821
|
+
class={editYoutubeWidth === '100%' ? styles.active : ''}
|
|
3822
|
+
onclick={() => editYoutubeWidth = '100%'}
|
|
3823
|
+
>
|
|
3824
|
+
100%
|
|
3825
|
+
</button>
|
|
3826
|
+
<button
|
|
3827
|
+
type="button"
|
|
3828
|
+
class={editYoutubeWidth === '75%' ? styles.active : ''}
|
|
3829
|
+
onclick={() => editYoutubeWidth = '75%'}
|
|
3830
|
+
>
|
|
3831
|
+
75%
|
|
3832
|
+
</button>
|
|
3833
|
+
<button
|
|
3834
|
+
type="button"
|
|
3835
|
+
class={editYoutubeWidth === '50%' ? styles.active : ''}
|
|
3836
|
+
onclick={() => editYoutubeWidth = '50%'}
|
|
3837
|
+
>
|
|
3838
|
+
50%
|
|
3839
|
+
</button>
|
|
3840
|
+
</div>
|
|
3841
|
+
</div>
|
|
3842
|
+
|
|
3843
|
+
<div class={styles.imageOptionRow}>
|
|
3844
|
+
<label>정렬</label>
|
|
3845
|
+
<div class={styles.imageAlignButtons}>
|
|
3846
|
+
<button
|
|
3847
|
+
type="button"
|
|
3848
|
+
class={editYoutubeAlign === 'left' ? styles.active : ''}
|
|
3849
|
+
onclick={() => editYoutubeAlign = 'left'}
|
|
3850
|
+
title="왼쪽 정렬"
|
|
3851
|
+
>
|
|
3852
|
+
<i class={styles.alignLeft}></i>
|
|
3853
|
+
</button>
|
|
3854
|
+
<button
|
|
3855
|
+
type="button"
|
|
3856
|
+
class={editYoutubeAlign === 'center' ? styles.active : ''}
|
|
3857
|
+
onclick={() => editYoutubeAlign = 'center'}
|
|
3858
|
+
title="가운데 정렬"
|
|
3859
|
+
>
|
|
3860
|
+
<i class={styles.alignCenter}></i>
|
|
3861
|
+
</button>
|
|
3862
|
+
<button
|
|
3863
|
+
type="button"
|
|
3864
|
+
class={editYoutubeAlign === 'right' ? styles.active : ''}
|
|
3865
|
+
onclick={() => editYoutubeAlign = 'right'}
|
|
3866
|
+
title="오른쪽 정렬"
|
|
3867
|
+
>
|
|
3868
|
+
<i class={styles.alignRight}></i>
|
|
3869
|
+
</button>
|
|
3870
|
+
</div>
|
|
3871
|
+
</div>
|
|
3872
|
+
</div>
|
|
3873
|
+
|
|
3874
|
+
<div class={styles.imageActions}>
|
|
3875
|
+
<button
|
|
3876
|
+
type="button"
|
|
3877
|
+
onclick={deleteYoutube}
|
|
3878
|
+
class={styles.danger}
|
|
3879
|
+
>
|
|
3880
|
+
삭제
|
|
3881
|
+
</button>
|
|
3882
|
+
<div style="display: flex; gap: 8px;">
|
|
3883
|
+
<button
|
|
3884
|
+
type="button"
|
|
3885
|
+
onclick={() => {
|
|
3886
|
+
deselectYoutube();
|
|
3887
|
+
}}
|
|
3888
|
+
class={styles.default}
|
|
3889
|
+
>
|
|
3890
|
+
취소
|
|
3891
|
+
</button>
|
|
3892
|
+
<button
|
|
3893
|
+
type="button"
|
|
3894
|
+
onclick={applyYoutubeEdit}
|
|
3895
|
+
class={styles.primary}
|
|
3896
|
+
>
|
|
3897
|
+
적용
|
|
3898
|
+
</button>
|
|
3899
|
+
</div>
|
|
3900
|
+
</div>
|
|
3901
|
+
</div>
|
|
3902
|
+
{/if}
|
|
3903
|
+
|
|
1300
3904
|
<!-- Validation message -->
|
|
1301
3905
|
{#if validator && $message}
|
|
1302
3906
|
<div class="validator" role="alert">
|