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.
@@ -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
- if (tagName === 'SPAN' || tagName === 'P' || tagName === 'DIV') {
535
- const style = (element as HTMLElement).style;
536
- const allowedStyles: string[] = [];
537
-
538
- if (style.color) allowedStyles.push(`color: ${style.color}`);
539
- if (style.backgroundColor) allowedStyles.push(`background-color: ${style.backgroundColor}`);
540
- if (style.textAlign) allowedStyles.push(`text-align: ${style.textAlign}`);
541
-
542
- if (allowedStyles.length > 0) {
543
- newElement.setAttribute('style', allowedStyles.join('; '));
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
- document.execCommand('insertText', false, text);
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 = () => execCommand('bold');
593
- const formatItalic = () => execCommand('italic');
594
- const formatUnderline = () => execCommand('underline');
595
- const formatStrikethrough = () => execCommand('strikethrough');
596
- const removeFormat = () => execCommand('removeFormat');
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
- execCommand('foreColor', color);
1191
+ applyColorStyle('color', color);
600
1192
  isTextColorOpen = false;
1193
+ savedSelection = null;
601
1194
  };
602
1195
 
603
1196
  const setBackgroundColor = (color: string) => {
604
- execCommand('hiliteColor', color);
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.toUpperCase());
1225
+ document.execCommand('formatBlock', false, format);
632
1226
  } else if (format === 'p') {
633
- document.execCommand('formatBlock', false, 'P');
1227
+ document.execCommand('formatBlock', false, 'p');
634
1228
  } else if (format.startsWith('p') && (format.match(/p[1-5](_semibold)?/))) {
635
- document.execCommand('formatBlock', false, 'P');
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
- // Code view toggle
832
- const toggleCodeView = () => {
833
- if (!editorRef) return;
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 (!isCodeView) {
836
- // Switch to code view
837
- savedEditorHeight = editorRef.offsetHeight;
838
- originalHtml = editorRef.innerHTML;
1466
+ if (cell && editorRef?.contains(cell)) {
1467
+ e.preventDefault();
1468
+ e.stopPropagation();
839
1469
 
840
- // Format HTML
841
- const formatted = originalHtml
842
- .replace(/></g, '>\n<')
843
- .replace(/(<\/?(?:p|div|h[1-6]|ul|ol|li|table|tr|td|th|tbody|thead|blockquote|pre|hr)[^>]*>)/gi, '\n$1\n')
844
- .split('\n')
845
- .filter(line => line.trim())
846
- .join('\n');
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
- codeContent = formatted;
849
- isCodeView = true;
850
- } else {
851
- // Switch back to WYSIWYG
852
- if (editorRef) {
853
- editorRef.innerHTML = codeContent.replace(/\n/g, '');
854
- handleInput();
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
- if (!target.closest(`.${styles.youtubeDropdown}`) && !target.closest(`.${styles.youtubeButton}`)) {
922
- isYoutubeDropdownOpen = false;
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="실행 취소 (Ctrl+Z)"
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="다시 실행 (Ctrl+Y)"
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
- <button
1005
- type="button"
1006
- class={styles.paragraphButton}
1007
- onclick={() => isParagraphDropdownOpen = !isParagraphDropdownOpen}
1008
- >
1009
- <span>{getCurrentStyleLabel()}</span>
1010
- <i class={styles.dropdownArrow}></i>
1011
- </button>
1012
- {#if isParagraphDropdownOpen}
1013
- <div class={styles.paragraphDropdown}>
1014
- {#each paragraphOptions as option}
1015
- <button
1016
- type="button"
1017
- class="{styles.paragraphOption} {option.className || ''}"
1018
- onclick={() => setParagraphFormat(option.value)}
1019
- >
1020
- {option.label}
1021
- </button>
1022
- {/each}
1023
- </div>
1024
- {/if}
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 class={styles.colorButton}>
2838
+ <div bind:this={textColorButtonRef} style="position: relative;">
1050
2839
  <button
1051
2840
  type="button"
1052
2841
  class={styles.toolbarButton}
1053
- onclick={() => { isTextColorOpen = !isTextColorOpen; isBgColorOpen = false; }}
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 class={styles.colorDropdown}>
1060
- <div class={styles.colorPalette}>
1061
- {#each colorPalette as row}
1062
- <div class={styles.colorRow}>
1063
- {#each row as color}
1064
- <button
1065
- type="button"
1066
- class={styles.colorCell}
1067
- style="background-color: {color};"
1068
- onclick={() => setTextColor(color)}
1069
- ></button>
1070
- {/each}
1071
- </div>
1072
- {/each}
1073
- </div>
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 class={styles.colorButton}>
2883
+ <div bind:this={bgColorButtonRef} style="position: relative;">
1078
2884
  <button
1079
2885
  type="button"
1080
2886
  class={styles.toolbarButton}
1081
- onclick={() => { isBgColorOpen = !isBgColorOpen; isTextColorOpen = false; }}
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 class={styles.colorDropdown}>
1088
- <div class={styles.colorPalette}>
1089
- {#each colorPalette as row}
1090
- <div class={styles.colorRow}>
1091
- {#each row as color}
1092
- <button
1093
- type="button"
1094
- class={styles.colorCell}
1095
- style="background-color: {color};"
1096
- onclick={() => setBackgroundColor(color)}
1097
- ></button>
1098
- {/each}
1099
- </div>
1100
- {/each}
1101
- </div>
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
- <div class={styles.alignButton}>
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
- onclick={() => isAlignDropdownOpen = !isAlignDropdownOpen}
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
- <span class={getCurrentAlignIcon()}></span>
1119
- <i class={styles.dropdownArrow}></i>
2952
+ <i class={getCurrentAlignIcon()}></i>
1120
2953
  </button>
1121
2954
  {#if isAlignDropdownOpen}
1122
- <div class={styles.alignDropdown}>
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
- <span class={styles[option.icon]}></span>
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
- </div>
1137
- {/if}
2973
+ {/if}
1138
2974
 
1139
- <!-- List -->
1140
- {#if isToolbarItemEnabled('list')}
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
- </div>
1149
- {/if}
2982
+ {/if}
1150
2983
 
1151
- <!-- Table -->
1152
- {#if isToolbarItemEnabled('table')}
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.tableGrid}>
1172
- {#each Array(6) as _, row}
1173
- <div class={styles.tableGridRow}>
1174
- {#each Array(6) as _, col}
1175
- <button
1176
- type="button"
1177
- class="{styles.tableGridCell} {row <= tableRows - 1 && col <= tableCols - 1 ? styles.selected : ''}"
1178
- onmouseenter={() => { tableRows = row + 1; tableCols = col + 1; }}
1179
- onclick={() => insertTable(row + 1, col + 1)}
1180
- ></button>
1181
- {/each}
1182
- </div>
1183
- {/each}
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
- <div class={styles.linkButton}>
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="URL을 입력하세요"
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
- <button type="button" class={styles.linkInsertButton} onclick={insertLink}>
1229
- 삽입
1230
- </button>
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.editorWrapper} style={editorStyle}>
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={resizable ? 'resize: vertical;' : ''}
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">