podo-ui 0.2.0 → 0.3.0

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.
@@ -1,55 +1,144 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useRef, useEffect, useState, useCallback } from 'react';
2
2
  import { v4 as uuid } from 'uuid';
3
- import suneditor from 'suneditor';
4
- import 'suneditor/dist/css/suneditor.min.css';
5
- import styles from './editor.module.scss';
6
- import lang from 'suneditor/src/lang';
7
- import {
8
- blockquote,
9
- align,
10
- font,
11
- fontSize,
12
- fontColor,
13
- hiliteColor,
14
- horizontalRule,
15
- list,
16
- formatBlock,
17
- lineHeight,
18
- template,
19
- paragraphStyle,
20
- textStyle,
21
- link,
22
- image,
23
- video,
24
- table,
25
- } from 'suneditor/src/plugins';
26
3
  import { z } from 'zod';
4
+ import styles from './editor.module.scss';
27
5
 
28
6
  interface Props {
29
7
  value: string;
30
8
  width?: string;
31
- height?: string;
9
+ height?: string | 'contents';
10
+ minHeight?: string;
11
+ maxHeight?: string;
12
+ resizable?: boolean;
32
13
  onChange: (content: string) => void;
33
- imageUploadUrl?: string;
34
- imageUploadHeader?: Record<string, string>;
35
14
  validator?: z.ZodType<unknown>;
15
+ placeholder?: string;
36
16
  }
37
17
 
38
- const SunEditor = ({
18
+ const Editor = ({
39
19
  value = '',
40
20
  width = '100%',
41
- height = '600px',
21
+ height = '400px',
22
+ minHeight,
23
+ maxHeight,
24
+ resizable = false,
42
25
  onChange,
43
- imageUploadUrl,
44
- imageUploadHeader,
45
26
  validator,
27
+ placeholder = '내용을 입력하세요...',
46
28
  }: Props) => {
47
29
  const [message, setMessage] = useState('');
48
30
  const [statusClass, setStatusClass] = useState('');
49
- const editorRef = useRef<HTMLTextAreaElement>(null);
50
- const editorID = `f${uuid()}`;
51
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
- const ref = useRef<any>();
31
+ const [currentParagraphStyle, setCurrentParagraphStyle] = useState('p');
32
+ const [isParagraphDropdownOpen, setIsParagraphDropdownOpen] = useState(false);
33
+ const [isTextColorOpen, setIsTextColorOpen] = useState(false);
34
+ const [isBgColorOpen, setIsBgColorOpen] = useState(false);
35
+ const [isAlignDropdownOpen, setIsAlignDropdownOpen] = useState(false);
36
+ const [currentAlign, setCurrentAlign] = useState('left');
37
+ const [isLinkDropdownOpen, setIsLinkDropdownOpen] = useState(false);
38
+ const [linkUrl, setLinkUrl] = useState('');
39
+ const [linkTarget, setLinkTarget] = useState('_blank');
40
+ const [isEditLinkPopupOpen, setIsEditLinkPopupOpen] = useState(false);
41
+ const [selectedLinkElement, setSelectedLinkElement] = useState<HTMLAnchorElement | null>(null);
42
+ const [editLinkUrl, setEditLinkUrl] = useState('');
43
+ const [editLinkTarget, setEditLinkTarget] = useState('_self');
44
+ const [savedSelection, setSavedSelection] = useState<Range | null>(null);
45
+ const [isImageDropdownOpen, setIsImageDropdownOpen] = useState(false);
46
+ const [imageTabMode, setImageTabMode] = useState<'file' | 'url'>('file'); // 탭 모드 추가
47
+ const [imageUrl, setImageUrl] = useState('');
48
+ const [imageWidth, setImageWidth] = useState('original'); // 기본값을 원본으로 변경
49
+ const [imageAlign, setImageAlign] = useState('left'); // 기본값을 좌측으로 변경
50
+ const [imageAlt, setImageAlt] = useState('');
51
+ const [imageFile, setImageFile] = useState<File | null>(null);
52
+ const [imagePreview, setImagePreview] = useState('');
53
+ const [savedImageSelection, setSavedImageSelection] = useState<Range | null>(null); // 이미지 삽입용 선택 영역 저장
54
+ const [selectedImage, setSelectedImage] = useState<HTMLImageElement | null>(null); // 선택된 이미지
55
+ const [isImageEditPopupOpen, setIsImageEditPopupOpen] = useState(false); // 이미지 편집 팝업 상태
56
+ const [editImageWidth, setEditImageWidth] = useState(''); // 편집 중인 이미지 크기
57
+ const [editImageAlign, setEditImageAlign] = useState('left'); // 편집 중인 이미지 정렬
58
+ const [editImageAlt, setEditImageAlt] = useState(''); // 편집 중인 이미지 대체 텍스트
59
+ const [isResizing, setIsResizing] = useState(false); // 리사이즈 중 여부
60
+ const [resizeStartData, setResizeStartData] = useState<{ startX: number; startY: number; startWidth: number; startHeight: number; handle: string } | null>(null);
61
+
62
+ // 유튜브 관련 상태
63
+ const [isYoutubeDropdownOpen, setIsYoutubeDropdownOpen] = useState(false);
64
+ const [youtubeUrl, setYoutubeUrl] = useState('');
65
+ const [savedYoutubeSelection, setSavedYoutubeSelection] = useState<Range | null>(null);
66
+ const [youtubeWidth, setYoutubeWidth] = useState('100%'); // 기본값 100%
67
+ const [youtubeAlign, setYoutubeAlign] = useState('center'); // 기본값 가운데
68
+ const [selectedYoutube, setSelectedYoutube] = useState<HTMLElement | null>(null); // 선택된 유튜브
69
+ const [isYoutubeEditPopupOpen, setIsYoutubeEditPopupOpen] = useState(false); // 유튜브 편집 팝업
70
+ const [editYoutubeWidth, setEditYoutubeWidth] = useState('100%'); // 편집 중인 유튜브 크기
71
+ const [editYoutubeAlign, setEditYoutubeAlign] = useState('center'); // 편집 중인 유튜브 정렬
72
+ const [isCodeView, setIsCodeView] = useState(false); // 코드보기 모드
73
+ const [codeContent, setCodeContent] = useState(''); // 코드보기 내용
74
+ const [savedEditorHeight, setSavedEditorHeight] = useState<number | null>(null); // 위지윅 에디터 높이 저장
75
+ const editorRef = useRef<HTMLDivElement>(null);
76
+ const codeEditorRef = useRef<HTMLTextAreaElement>(null);
77
+ const containerRef = useRef<HTMLDivElement>(null);
78
+ const fileInputRef = useRef<HTMLInputElement>(null);
79
+ const paragraphButtonRef = useRef<HTMLDivElement>(null);
80
+ const textColorButtonRef = useRef<HTMLDivElement>(null);
81
+ const bgColorButtonRef = useRef<HTMLDivElement>(null);
82
+ const alignButtonRef = useRef<HTMLDivElement>(null);
83
+ const linkButtonRef = useRef<HTMLDivElement>(null);
84
+ const imageButtonRef = useRef<HTMLDivElement>(null);
85
+ const youtubeButtonRef = useRef<HTMLDivElement>(null);
86
+ const imageFileInputRef = useRef<HTMLInputElement>(null);
87
+ // 클라이언트에서만 ID 생성 (Vite React용)
88
+ const [editorID, setEditorID] = useState<string>('podo-editor');
89
+
90
+ // 색상 팔레트 정의
91
+ const colorPalette = [
92
+ // 첫 번째 줄: 기본 색상
93
+ ['#000000', '#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff', '#ffffff'],
94
+ // 두 번째 줄: 밝은 톤
95
+ ['#808080', '#ff8080', '#ffff80', '#80ff80', '#80ffff', '#8080ff', '#ff80ff', '#c0c0c0'],
96
+ // 세 번째 줄: 중간 톤
97
+ ['#404040', '#800000', '#808000', '#008000', '#008080', '#000080', '#800080', '#808080'],
98
+ // 네 번째 줄: 어두운 톤
99
+ ['#202020', '#400000', '#404000', '#004000', '#004040', '#000040', '#400040', '#404040'],
100
+ ];
101
+
102
+ // 정렬 옵션 정의
103
+ const alignOptions = [
104
+ { value: 'left', label: '왼쪽 정렬', icon: 'alignLeft' },
105
+ { value: 'center', label: '가운데 정렬', icon: 'alignCenter' },
106
+ { value: 'right', label: '오른쪽 정렬', icon: 'alignRight' },
107
+ ];
108
+
109
+ // 문단 형식 옵션 정의
110
+ const paragraphOptions = [
111
+ { value: 'h1', label: '제목 1' },
112
+ { value: 'h2', label: '제목 2' },
113
+ { value: 'h3', label: '제목 3' },
114
+ { value: 'p', label: '본문', className: styles.pDefault },
115
+ { value: 'p1', label: 'P1', className: styles.p1Preview },
116
+ { value: 'p2', label: 'P2', className: styles.p2Preview },
117
+ { value: 'p3', label: 'P3', className: styles.p3Preview },
118
+ { value: 'p3_semibold', label: 'P3 Semibold', className: styles.p3_semiboldPreview },
119
+ { value: 'p4', label: 'P4', className: styles.p4Preview },
120
+ { value: 'p4_semibold', label: 'P4 Semibold', className: styles.p4_semiboldPreview },
121
+ { value: 'p5', label: 'P5', className: styles.p5Preview },
122
+ { value: 'p5_semibold', label: 'P5 Semibold', className: styles.p5_semiboldPreview },
123
+ ];
124
+
125
+ // 현재 선택된 스타일의 라벨 가져오기
126
+ const getCurrentStyleLabel = () => {
127
+ const option = paragraphOptions.find(opt => opt.value === currentParagraphStyle);
128
+ return option ? option.label : '문단 형식';
129
+ };
130
+
131
+ // 현재 정렬 상태의 라벨 가져오기
132
+ const getCurrentAlignLabel = () => {
133
+ const option = alignOptions.find(opt => opt.value === currentAlign);
134
+ return option ? option.label : '왼쪽 정렬';
135
+ };
136
+
137
+ // 현재 정렬 상태의 아이콘 가져오기
138
+ const getCurrentAlignIcon = () => {
139
+ const option = alignOptions.find(opt => opt.value === currentAlign);
140
+ return option ? styles[option.icon] : styles.alignLeft;
141
+ };
53
142
 
54
143
  const validateHandler = (content: string) => {
55
144
  setMessage('');
@@ -66,106 +155,2890 @@ const SunEditor = ({
66
155
  }
67
156
  }
68
157
  };
69
- const toolbar = [
70
- ['undo', 'redo'],
71
- ['formatBlock', 'bold', 'underline', 'italic', 'strike'],
72
- ['fontColor', 'hiliteColor'],
73
- ['align', 'list', 'table'],
74
- ['link', 'image', 'video'],
75
- ['fullScreen', 'codeView'],
76
- ];
77
- const editorOptions = {
78
- lang: lang.ko,
79
- plugins: {
80
- blockquote,
81
- align,
82
- font,
83
- fontSize,
84
- fontColor,
85
- hiliteColor,
86
- horizontalRule,
87
- list,
88
- table,
89
- formatBlock,
90
- lineHeight,
91
- template,
92
- paragraphStyle,
93
- textStyle,
94
- link,
95
- image,
96
- video,
97
- },
98
- width: width,
99
- height: height,
100
- buttonList: toolbar,
101
- formats: [
102
- 'h1',
103
- 'h2',
104
- 'h3',
105
- {
106
- tag: 'p',
107
- name: '본문',
108
- command: 'replace',
109
- class: '',
110
- },
111
- {
112
- tag: 'div',
113
- name: '인용구',
114
- command: 'range',
115
- class: '__se__format__range__quote',
116
- },
117
- ],
118
- resizingBar: false,
119
- imageUploadUrl,
120
- imageUploadHeader,
121
- icons: {
122
- undo: '<i class="editor undo">실행취소</i>',
123
- redo: '<i class="editor redo">다시실행</i>',
124
- bold: '<i class="editor bold">굵게</i>',
125
- underline: '<i class="editor underline">밑줄</i>',
126
- italic: '<i class="editor italic">기울이기</i>',
127
- strike: '<i class="editor strike">가운데선</i>',
128
- font_color: '<i class="editor font">글자색</i>',
129
- highlight_color: '<i class="editor highlighter">배경색</i>',
130
- align_left: '<i class="editor align-left">왼쪽 정렬</i>',
131
- align_center: '<i class="editor align-center">가운데 정렬</i>',
132
- align_right: '<i class="editor align-right">우측 정렬</i>',
133
- align_justify: '<i class="editor align-justify">양쪽 정렬</i>',
134
-
135
- list_bullets: '<i class="editor list-ul">목록</i>',
136
- list_number: '<i class="editor list-ol">숫자 목록</i>',
137
- table: '<i class="editor table">테이블</i>',
138
- link: '<i class="editor link">링크</i>',
139
- image: '<i class="editor image">사진</i>',
140
- video: '<i class="editor video">비디오</i>',
141
- expansion: '<i class="editor expand">확장</i>',
142
- code_view: '<i class="editor code">코드</i>',
143
- },
158
+
159
+
160
+ const detectCurrentAlign = () => {
161
+ // 정렬 상태 감지
162
+ if (document.queryCommandState('justifyLeft')) {
163
+ setCurrentAlign('left');
164
+ } else if (document.queryCommandState('justifyCenter')) {
165
+ setCurrentAlign('center');
166
+ } else if (document.queryCommandState('justifyRight')) {
167
+ setCurrentAlign('right');
168
+ } else {
169
+ // 기본값은 왼쪽 정렬
170
+ setCurrentAlign('left');
171
+ }
144
172
  };
145
173
 
146
- useEffect(() => {
147
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
148
- ref.current = suneditor.create(editorID, editorOptions as any);
174
+ const detectCurrentParagraphStyle = () => {
175
+ const selection = window.getSelection();
176
+ if (!selection || selection.rangeCount === 0) {
177
+ setCurrentParagraphStyle('p');
178
+ return;
179
+ }
180
+
181
+ let container = selection.getRangeAt(0).commonAncestorContainer;
182
+ if (container.nodeType === Node.TEXT_NODE) {
183
+ container = container.parentNode as Element;
184
+ }
185
+
186
+ // 상위 블록 요소 찾기
187
+ while (container && container !== editorRef.current) {
188
+ const element = container as Element;
189
+ if (element.tagName) {
190
+ const tagName = element.tagName.toLowerCase();
191
+
192
+ // H1, H2, H3 체크
193
+ if (tagName === 'h1' || tagName === 'h2' || tagName === 'h3') {
194
+ setCurrentParagraphStyle(tagName);
195
+ return;
196
+ }
197
+
198
+ // P 태그 체크
199
+ if (tagName === 'p') {
200
+ // 클래스 확인
201
+ if (element.className) {
202
+ // p1, p2, p3, p4, p5, p1_semibold, p2_semibold 등의 클래스 찾기
203
+ const classNames = Object.keys(styles);
204
+ for (const className of classNames) {
205
+ if (className.match(/^p[1-5](_semibold)?$/) &&
206
+ element.classList.contains(styles[className])) {
207
+ setCurrentParagraphStyle(className);
208
+ return;
209
+ }
210
+ }
211
+ }
212
+ // 클래스가 없으면 일반 p
213
+ setCurrentParagraphStyle('p');
214
+ return;
215
+ }
216
+
217
+ // DIV나 기타 블록 요소는 본문으로 처리
218
+ if (tagName === 'div' || tagName === 'blockquote' || tagName === 'pre') {
219
+ setCurrentParagraphStyle('p');
220
+ return;
221
+ }
222
+ }
223
+ container = (container as Element).parentNode as Element;
224
+ }
225
+
226
+ // 기본값은 본문
227
+ setCurrentParagraphStyle('p');
228
+ };
229
+
230
+ const handleInput = useCallback(() => {
231
+ if (editorRef.current) {
232
+ const content = editorRef.current.innerHTML;
149
233
 
150
- ref.current.onChange = function (content: string) {
151
234
  onChange(content);
152
235
  validateHandler(content);
236
+ detectCurrentParagraphStyle();
237
+ detectCurrentAlign();
238
+ }
239
+ // eslint-disable-next-line react-hooks/exhaustive-deps
240
+ }, [onChange]);
241
+
242
+ const execCommand = (command: string, value: string | undefined = undefined) => {
243
+ // bold, italic, underline, strikeThrough일 때 선택 영역이 없으면 아무것도 하지 않음
244
+ if (['bold', 'italic', 'underline', 'strikeThrough'].includes(command)) {
245
+ const selection = window.getSelection();
246
+ if (selection && selection.isCollapsed) {
247
+ // 선택 영역이 없으면 실행하지 않음
248
+ return;
249
+ }
250
+ }
251
+
252
+ document.execCommand(command, false, value);
253
+ editorRef.current?.focus();
254
+ handleInput();
255
+ };
256
+
257
+ // 코드보기 토글
258
+ const toggleCodeView = () => {
259
+ if (isCodeView) {
260
+ // 코드보기에서 일반 모드로 전환
261
+ setIsCodeView(false);
262
+ setSavedEditorHeight(null); // 저장된 높이 초기화
263
+ // 다음 렌더링 사이클에서 에디터 내용 업데이트
264
+ setTimeout(() => {
265
+ if (editorRef.current && codeContent !== undefined) {
266
+ editorRef.current.innerHTML = codeContent;
267
+ handleInput();
268
+ }
269
+ }, 0);
270
+ } else {
271
+ // 일반 모드에서 코드보기로 전환
272
+ if (editorRef.current) {
273
+ // height가 contents일 때 현재 에디터 높이 저장
274
+ if (height === 'contents') {
275
+ const currentHeight = editorRef.current.scrollHeight;
276
+ setSavedEditorHeight(currentHeight);
277
+ }
278
+
279
+ // 현재 HTML을 포맷팅
280
+ const html = editorRef.current.innerHTML;
281
+ const formattedHtml = formatHtml(html);
282
+ setCodeContent(formattedHtml);
283
+ setIsCodeView(true);
284
+ }
285
+ }
286
+ };
287
+
288
+ // HTML 포맷팅 함수
289
+ const formatHtml = (html: string): string => {
290
+ // 기본적인 HTML 포맷팅
291
+ const formatted = html
292
+ .replace(/></g, '>\n<') // 태그 사이에 줄바꿈 추가
293
+ .replace(/(<div|<p|<h[1-6]|<ul|<ol|<li|<blockquote)/gi, '\n$1') // 블록 요소 앞에 줄바꿈
294
+ .replace(/(<\/div>|<\/p>|<\/h[1-6]>|<\/ul>|<\/ol>|<\/li>|<\/blockquote>)/gi, '$1\n'); // 블록 요소 뒤에 줄바꿈
295
+
296
+ // 들여쓰기 추가
297
+ const lines = formatted.split('\n');
298
+ let indentLevel = 0;
299
+ const indentedLines = lines.map(line => {
300
+ const trimmed = line.trim();
301
+ if (!trimmed) return '';
302
+
303
+ // 닫는 태그인 경우 들여쓰기 레벨 감소
304
+ if (trimmed.startsWith('</')) {
305
+ indentLevel = Math.max(0, indentLevel - 1);
306
+ const indented = ' '.repeat(indentLevel) + trimmed;
307
+ return indented;
308
+ }
309
+
310
+ // 자체 닫는 태그가 아닌 여는 태그인 경우
311
+ const indented = ' '.repeat(indentLevel) + trimmed;
312
+ if (trimmed.startsWith('<') && !trimmed.startsWith('<!') &&
313
+ !trimmed.endsWith('/>') && trimmed.includes('>') &&
314
+ !trimmed.includes('</')) {
315
+ indentLevel++;
316
+ }
317
+
318
+ return indented;
319
+ });
320
+
321
+ return indentedLines.filter(line => line !== '').join('\n');
322
+ };
323
+
324
+ // 코드 에디터 내용 변경 처리
325
+ const handleCodeChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
326
+ setCodeContent(e.target.value);
327
+ };
328
+
329
+ const applyParagraphStyle = (value: string) => {
330
+ // 빈 값이면 본문으로 설정
331
+ if (!value) {
332
+ value = 'p';
333
+ }
334
+
335
+ // h1, h2, h3는 formatBlock 사용
336
+ if (value === 'h1' || value === 'h2' || value === 'h3') {
337
+ execCommand('formatBlock', value);
338
+ setCurrentParagraphStyle(value);
339
+ }
340
+ // 본문은 p 태그로
341
+ else if (value === 'p') {
342
+ execCommand('formatBlock', 'p');
343
+ setCurrentParagraphStyle('p');
344
+ }
345
+ // p1~p5 및 p1_semibold~p5_semibold 스타일은 클래스 적용
346
+ else if (value.match(/^p[1-5](_semibold)?$/)) {
347
+ // 먼저 p 태그로 만들고
348
+ execCommand('formatBlock', 'p');
349
+
350
+ // 잠시 후 클래스 적용
351
+ setTimeout(() => {
352
+ const selection = window.getSelection();
353
+ if (!selection || selection.rangeCount === 0) return;
354
+
355
+ let container = selection.getRangeAt(0).commonAncestorContainer;
356
+ if (container.nodeType === Node.TEXT_NODE) {
357
+ container = container.parentNode as Element;
358
+ }
359
+
360
+ // 상위 블록 요소 찾기
361
+ while (container && container !== editorRef.current) {
362
+ const element = container as Element;
363
+ if (element.tagName && ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'DIV'].includes(element.tagName)) {
364
+ // 클래스 적용
365
+ element.className = styles[value];
366
+ setCurrentParagraphStyle(value);
367
+ break;
368
+ }
369
+ container = element.parentNode as Element;
370
+ }
371
+ handleInput();
372
+ }, 10);
373
+ }
374
+
375
+ // 드롭다운 닫기
376
+ setIsParagraphDropdownOpen(false);
377
+ };
378
+
379
+ const applyLink = () => {
380
+ if (linkUrl && savedSelection) {
381
+ restoreSelection(savedSelection);
382
+
383
+ const selection = window.getSelection();
384
+ if (selection && !selection.isCollapsed) {
385
+ const range = selection.getRangeAt(0);
386
+ const selectedText = range.toString();
387
+
388
+ // Create link element
389
+ const link = document.createElement('a');
390
+ link.href = linkUrl;
391
+ link.textContent = selectedText;
392
+
393
+ // Set target attribute
394
+ if (linkTarget === '_blank') {
395
+ link.target = '_blank';
396
+ link.rel = 'noopener noreferrer';
397
+ }
398
+
399
+ // Replace selection with link
400
+ range.deleteContents();
401
+ range.insertNode(link);
402
+
403
+ // Clear and close dropdown
404
+ setLinkUrl('');
405
+ setLinkTarget('_blank');
406
+ setIsLinkDropdownOpen(false);
407
+ setSavedSelection(null);
408
+
409
+ editorRef.current?.focus();
410
+ handleInput();
411
+ }
412
+ }
413
+ };
414
+
415
+ const openLinkDropdown = () => {
416
+ const selection = window.getSelection();
417
+ if (selection && !selection.isCollapsed) {
418
+ // Save selection
419
+ const range = saveSelection();
420
+ setSavedSelection(range);
421
+ setIsLinkDropdownOpen(true);
422
+ setIsParagraphDropdownOpen(false);
423
+ setIsTextColorOpen(false);
424
+ setIsBgColorOpen(false);
425
+ setIsAlignDropdownOpen(false);
426
+ }
427
+ };
428
+
429
+ // 이미지 선택
430
+ const selectImage = (img: HTMLImageElement) => {
431
+ // 기존 선택 해제
432
+ if (selectedImage) {
433
+ deselectImage();
434
+ }
435
+
436
+ setSelectedImage(img);
437
+
438
+ // 이미지 주위에 wrapper 추가
439
+ const wrapper = document.createElement('div');
440
+ wrapper.className = 'image-wrapper';
441
+ wrapper.style.position = 'relative';
442
+ wrapper.style.display = 'inline-block';
443
+ wrapper.style.border = '2px solid #0084ff';
444
+ wrapper.style.padding = '0';
445
+
446
+ // 리사이즈 핸들 추가
447
+ const handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
448
+ handles.forEach(handle => {
449
+ const handleDiv = document.createElement('div');
450
+ handleDiv.className = `resize-handle resize-handle-${handle}`;
451
+ handleDiv.dataset.handle = handle;
452
+ handleDiv.style.position = 'absolute';
453
+ handleDiv.style.width = '8px';
454
+ handleDiv.style.height = '8px';
455
+ handleDiv.style.backgroundColor = '#0084ff';
456
+ handleDiv.style.border = '1px solid white';
457
+ handleDiv.style.borderRadius = '2px';
458
+ handleDiv.style.cursor = `${handle}-resize`;
459
+
460
+ // 핸들 위치 설정
461
+ switch(handle) {
462
+ case 'nw': handleDiv.style.top = '-5px'; handleDiv.style.left = '-5px'; break;
463
+ case 'n': handleDiv.style.top = '-5px'; handleDiv.style.left = '50%'; handleDiv.style.transform = 'translateX(-50%)'; break;
464
+ case 'ne': handleDiv.style.top = '-5px'; handleDiv.style.right = '-5px'; break;
465
+ case 'e': handleDiv.style.top = '50%'; handleDiv.style.right = '-5px'; handleDiv.style.transform = 'translateY(-50%)'; break;
466
+ case 'se': handleDiv.style.bottom = '-5px'; handleDiv.style.right = '-5px'; break;
467
+ case 's': handleDiv.style.bottom = '-5px'; handleDiv.style.left = '50%'; handleDiv.style.transform = 'translateX(-50%)'; break;
468
+ case 'sw': handleDiv.style.bottom = '-5px'; handleDiv.style.left = '-5px'; break;
469
+ case 'w': handleDiv.style.top = '50%'; handleDiv.style.left = '-5px'; handleDiv.style.transform = 'translateY(-50%)'; break;
470
+ }
471
+
472
+ // 리사이즈 이벤트 핸들러
473
+ handleDiv.onmousedown = (e) => {
474
+ e.preventDefault();
475
+ e.stopPropagation();
476
+ startResize(e, img, handle);
477
+ };
478
+
479
+ wrapper.appendChild(handleDiv);
480
+ });
481
+
482
+ // 이미지를 wrapper로 감싸기
483
+ const parent = img.parentNode;
484
+ parent?.insertBefore(wrapper, img);
485
+ wrapper.appendChild(img);
486
+
487
+ // 편집 팝업 데이터 설정
488
+ // 이미지 크기 확인
489
+ if (img.style.width) {
490
+ setEditImageWidth(img.style.width);
491
+ } else {
492
+ setEditImageWidth('original');
493
+ }
494
+
495
+ // 이미지의 정렬 상태 확인 - 부모 div의 textAlign 체크
496
+ let container = img.parentElement;
497
+ let currentAlign = 'left'; // 기본값
498
+
499
+ // 부모 요소를 올라가며 textAlign이 설정된 div 찾기
500
+ while (container && container !== editorRef.current) {
501
+ if (container.tagName === 'DIV' && container.style.textAlign) {
502
+ currentAlign = container.style.textAlign;
503
+ break;
504
+ }
505
+ container = container.parentElement;
506
+ }
507
+
508
+ setEditImageAlign(currentAlign);
509
+ setEditImageAlt(img.alt || '');
510
+
511
+ // 약간의 지연 후 편집창 열기 (클릭 이벤트 완전 처리 후)
512
+ setTimeout(() => {
513
+ setIsImageEditPopupOpen(true);
514
+ }, 50);
515
+ };
516
+
517
+ // 이미지 선택 해제
518
+ const deselectImage = () => {
519
+ if (!selectedImage) return;
520
+
521
+ // wrapper 제거
522
+ const wrapper = selectedImage.parentElement;
523
+ if (wrapper && wrapper.classList.contains('image-wrapper')) {
524
+ const parent = wrapper.parentNode;
525
+ if (parent) {
526
+ try {
527
+ // 이미지를 wrapper 밖으로 이동
528
+ parent.insertBefore(selectedImage, wrapper);
529
+ // wrapper 제거
530
+ wrapper.remove();
531
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
532
+ } catch (e) {
533
+ // 이미 제거된 경우 무시
534
+ }
535
+ }
536
+ }
537
+
538
+ // 이미지 draggable 속성 제거
539
+ if (selectedImage) {
540
+ selectedImage.draggable = false;
541
+ }
542
+
543
+ // 상태 초기화
544
+ setSelectedImage(null);
545
+ setIsImageEditPopupOpen(false);
546
+ setIsResizing(false);
547
+ setResizeStartData(null);
548
+ };
549
+
550
+ // 리사이즈 시작
551
+ const startResize = (e: MouseEvent, img: HTMLImageElement, handle: string) => {
552
+ setIsResizing(true);
553
+ setResizeStartData({
554
+ startX: e.clientX,
555
+ startY: e.clientY,
556
+ startWidth: img.offsetWidth,
557
+ startHeight: img.offsetHeight,
558
+ handle
559
+ });
560
+ };
561
+
562
+ // 이미지 편집 적용
563
+ const applyImageEdit = () => {
564
+ if (!selectedImage) return;
565
+
566
+ // 크기 적용
567
+ if (editImageWidth) {
568
+ if (editImageWidth.includes('%')) {
569
+ selectedImage.style.width = editImageWidth;
570
+ selectedImage.style.height = 'auto';
571
+ } else if (editImageWidth === 'original') {
572
+ selectedImage.style.width = '';
573
+ selectedImage.style.height = '';
574
+ } else {
575
+ selectedImage.style.width = editImageWidth;
576
+ selectedImage.style.height = 'auto';
577
+ }
578
+ }
579
+
580
+ // 정렬 적용 - 이미지를 감싸는 정렬 컨테이너 찾기 또는 생성
581
+ let alignContainer = selectedImage.parentElement;
582
+
583
+ // wrapper가 있으면 그 부모를 확인
584
+ if (alignContainer?.classList.contains('image-wrapper')) {
585
+ alignContainer = alignContainer.parentElement;
586
+ }
587
+
588
+ // 정렬 컨테이너가 이미 있는지 확인 (div이고 textAlign이 설정된 경우)
589
+ if (alignContainer && alignContainer.tagName === 'DIV' && alignContainer !== editorRef.current) {
590
+ // 기존 컨테이너의 정렬 변경
591
+ alignContainer.style.textAlign = editImageAlign;
592
+ } else {
593
+ // 정렬 컨테이너가 없으면 새로 생성
594
+ const newContainer = document.createElement('div');
595
+ newContainer.style.textAlign = editImageAlign;
596
+
597
+ // wrapper나 이미지를 새 컨테이너로 감싸기
598
+ const elementToWrap = selectedImage.parentElement?.classList.contains('image-wrapper')
599
+ ? selectedImage.parentElement
600
+ : selectedImage;
601
+
602
+ if (elementToWrap.parentNode) {
603
+ elementToWrap.parentNode.insertBefore(newContainer, elementToWrap);
604
+ newContainer.appendChild(elementToWrap);
605
+ }
606
+ }
607
+
608
+ // 대체 텍스트 적용
609
+ selectedImage.alt = editImageAlt;
610
+
611
+ // 선택 해제
612
+ deselectImage();
613
+ handleInput();
614
+ };
615
+
616
+ // 이미지 삭제
617
+ const deleteImage = () => {
618
+ if (!selectedImage) return;
619
+
620
+ // 먼저 선택 해제 (상태 초기화)
621
+ const imageToDelete = selectedImage;
622
+ deselectImage();
623
+
624
+ // wrapper가 있는 경우 wrapper를 찾아서 제거
625
+ let elementToRemove: HTMLElement = imageToDelete;
626
+ let parent = imageToDelete.parentElement;
627
+
628
+ // wrapper를 거슬러 올라가며 정렬 컨테이너까지 찾기
629
+ while (parent && parent !== editorRef.current) {
630
+ if (parent.classList.contains('image-wrapper') ||
631
+ (parent.tagName === 'DIV' && parent.style.textAlign)) {
632
+ elementToRemove = parent;
633
+ parent = parent.parentElement;
634
+ } else {
635
+ break;
636
+ }
637
+ }
638
+
639
+ // DOM에서 제거
640
+ if (elementToRemove.parentNode) {
641
+ elementToRemove.parentNode.removeChild(elementToRemove);
642
+ }
643
+
644
+ handleInput();
645
+ };
646
+
647
+ // 링크 요소 클릭 감지
648
+ const handleEditorClick = (e: React.MouseEvent<HTMLDivElement>) => {
649
+ const target = e.target as HTMLElement;
650
+
651
+ // 리사이즈 핸들 클릭은 무시
652
+ if (target.classList.contains('resize-handle')) {
653
+ return;
654
+ }
655
+
656
+ // 이미지 편집 팝업 클릭은 무시
657
+ if (target.closest(`.${styles.imageDropdown}`)) {
658
+ return;
659
+ }
660
+
661
+ // 유튜브 편집 팝업 클릭은 무시
662
+ if (target.closest(`.${styles.youtubeEditPopup}`)) {
663
+ return;
664
+ }
665
+
666
+ // 유튜브 오버레이 클릭 감지
667
+ if ((target.classList.contains('youtube-overlay') || target.closest('.youtube-container')) && editorRef.current?.contains(target)) {
668
+ e.preventDefault();
669
+ e.stopPropagation();
670
+
671
+ const youtubeContainer = target.closest('.youtube-container') as HTMLElement;
672
+ if (youtubeContainer) {
673
+ // 이미 선택된 유튜브가 아닌 경우에만 선택
674
+ if (selectedYoutube !== youtubeContainer) {
675
+ // 기존 선택 해제
676
+ if (selectedYoutube) {
677
+ deselectYoutube();
678
+ }
679
+ if (selectedImage) {
680
+ deselectImage();
681
+ }
682
+ selectYoutube(youtubeContainer);
683
+ } else {
684
+ // 같은 유튜브를 다시 클릭하면 편집창 토글
685
+ setIsYoutubeEditPopupOpen(!isYoutubeEditPopupOpen);
686
+ }
687
+ }
688
+ return;
689
+ }
690
+
691
+ // 이미지 요소인지 확인
692
+ if (target.tagName === 'IMG' && editorRef.current?.contains(target)) {
693
+ e.preventDefault();
694
+ e.stopPropagation();
695
+ const img = target as HTMLImageElement;
696
+
697
+ // 이미 선택된 이미지가 아닌 경우에만 선택
698
+ if (selectedImage !== img) {
699
+ // 기존 선택 해제
700
+ if (selectedImage) {
701
+ deselectImage();
702
+ }
703
+ if (selectedYoutube) {
704
+ deselectYoutube();
705
+ }
706
+ selectImage(img);
707
+ } else {
708
+ // 같은 이미지를 다시 클릭하면 편집창 토글
709
+ setIsImageEditPopupOpen(!isImageEditPopupOpen);
710
+ }
711
+ return;
712
+ }
713
+
714
+ // 기존 선택된 이미지가 있으면 선택 해제
715
+ // image-wrapper 또는 리사이즈 핸들이 아닌 경우
716
+ // 단, 리사이즈 중일 때는 선택 해제하지 않음
717
+ if (selectedImage && !target.closest('.image-wrapper') && !isResizing) {
718
+ deselectImage();
719
+ }
720
+
721
+ // 기존 선택된 유튜브가 있으면 선택 해제
722
+ // 단, 리사이즈 중일 때는 선택 해제하지 않음
723
+ if (selectedYoutube && !target.closest('.youtube-wrapper') && !isResizing) {
724
+ deselectYoutube();
725
+ }
726
+
727
+ // 링크 요소인지 확인
728
+ const linkElement = target.closest('a') as HTMLAnchorElement;
729
+ if (linkElement && editorRef.current?.contains(linkElement)) {
730
+ e.preventDefault();
731
+ setSelectedLinkElement(linkElement);
732
+ setEditLinkUrl(linkElement.href);
733
+ setEditLinkTarget(linkElement.target || '_self');
734
+ setIsEditLinkPopupOpen(true);
735
+ } else {
736
+ // 일반 클릭 처리
737
+ detectCurrentParagraphStyle();
738
+ detectCurrentAlign();
739
+ }
740
+ };
741
+
742
+ // 링크 수정
743
+ const updateLink = () => {
744
+ if (selectedLinkElement && editLinkUrl) {
745
+ selectedLinkElement.href = editLinkUrl;
746
+
747
+ if (editLinkTarget === '_blank') {
748
+ selectedLinkElement.target = '_blank';
749
+ selectedLinkElement.rel = 'noopener noreferrer';
750
+ } else {
751
+ selectedLinkElement.removeAttribute('target');
752
+ selectedLinkElement.removeAttribute('rel');
753
+ }
754
+
755
+ setIsEditLinkPopupOpen(false);
756
+ setSelectedLinkElement(null);
757
+ editorRef.current?.focus();
758
+ handleInput();
759
+ }
760
+ };
761
+
762
+ // 링크 삭제
763
+ const removeLink = () => {
764
+ if (selectedLinkElement) {
765
+ const parent = selectedLinkElement.parentNode;
766
+ const textContent = selectedLinkElement.textContent || '';
767
+ const textNode = document.createTextNode(textContent);
768
+
769
+ parent?.replaceChild(textNode, selectedLinkElement);
770
+
771
+ setIsEditLinkPopupOpen(false);
772
+ setSelectedLinkElement(null);
773
+ editorRef.current?.focus();
774
+ handleInput();
775
+ }
776
+ };
777
+
778
+ const openImageDropdown = (e?: React.MouseEvent) => {
779
+ e?.stopPropagation();
780
+ // 현재 선택 영역 저장
781
+ const selection = window.getSelection();
782
+ if (selection && selection.rangeCount > 0) {
783
+ const range = selection.getRangeAt(0).cloneRange();
784
+ setSavedImageSelection(range);
785
+ } else {
786
+ setSavedImageSelection(null);
787
+ }
788
+
789
+ setIsImageDropdownOpen(true);
790
+ setImageTabMode('file'); // 기본값으로 파일 업로드 탭 선택
791
+ setIsParagraphDropdownOpen(false);
792
+ setIsTextColorOpen(false);
793
+ setIsBgColorOpen(false);
794
+ setIsAlignDropdownOpen(false);
795
+ setIsLinkDropdownOpen(false);
796
+ };
797
+
798
+ const insertImage = async () => {
799
+ let imageSrc = '';
800
+
801
+ // 파일이 업로드된 경우
802
+ if (imageFile && imagePreview) {
803
+ imageSrc = imagePreview;
804
+ }
805
+ // URL이 입력된 경우
806
+ else if (imageUrl) {
807
+ // URL 유효성 검사
808
+ try {
809
+ const testImg = new Image();
810
+
811
+ await new Promise((resolve, reject) => {
812
+ const timeout = setTimeout(() => {
813
+ reject(new Error('Timeout'));
814
+ }, 5000); // 5초 타임아웃
815
+
816
+ testImg.onload = () => {
817
+ clearTimeout(timeout);
818
+ resolve(true);
819
+ };
820
+
821
+ testImg.onerror = () => {
822
+ clearTimeout(timeout);
823
+ reject(new Error('Load failed'));
824
+ };
825
+
826
+ // CORS를 우회하기 위해 crossOrigin 설정하지 않음
827
+ testImg.src = imageUrl;
828
+ });
829
+
830
+ imageSrc = imageUrl;
831
+ } catch (error) {
832
+ console.error('Image validation failed:', error);
833
+ alert(`이미지를 불러올 수 없습니다.\n\n가능한 원인:\n1. 잘못된 이미지 URL\n2. CORS 정책으로 인한 차단 (외부 도메인)\n3. 네트워크 연결 문제\n4. 이미지가 존재하지 않음\n\nURL: ${imageUrl}\n\n💡 팁: CORS 정책으로 차단된 경우, 이미지를 직접 다운로드 후 파일 업로드를 사용해주세요.`);
834
+ return;
835
+ }
836
+ }
837
+
838
+ if (!imageSrc) return;
839
+
840
+ // 이미지 엘리먼트 생성
841
+ const img = document.createElement('img');
842
+ img.src = imageSrc;
843
+ img.alt = imageAlt || '';
844
+
845
+ // display를 inline-block으로 설정하여 정렬이 작동하도록 함
846
+ img.style.display = 'inline-block';
847
+ img.style.verticalAlign = 'middle'; // 수직 정렬 개선
848
+
849
+ // 이미지 로드 에러 처리
850
+ img.onerror = () => {
851
+ console.error('Image load failed:', imageSrc);
852
+ alert(`이미지를 불러올 수 없습니다.\n\n가능한 원인:\n1. 잘못된 이미지 URL\n2. CORS 정책으로 인한 차단\n3. 네트워크 연결 문제\n\nURL: ${imageSrc}`);
853
+
854
+ // 에러 발생 시 삽입된 이미지 제거
855
+ if (img.parentNode) {
856
+ img.parentNode.removeChild(img);
857
+ }
153
858
  };
154
859
 
155
- return () => {
156
- ref.current.destroy();
860
+ // 이미지 로드 성공 처리
861
+ img.onload = () => {
862
+ // 이미지 로드 성공
157
863
  };
158
- // eslint-disable-next-line react-hooks/exhaustive-deps
159
- }, []);
160
864
 
161
- return (
162
- <div className={`${styles.editor} ${statusClass}`}>
163
- <textarea ref={editorRef} id={editorID} defaultValue={value} />
164
- {validator && message && (
165
- <div className={styles.validator}>{message}</div>
166
- )}
865
+ // 크기 설정
866
+ if (imageWidth === '100%') {
867
+ img.style.width = '100%';
868
+ img.style.height = 'auto';
869
+ } else if (imageWidth === '75%') {
870
+ img.style.width = '75%';
871
+ img.style.height = 'auto';
872
+ } else if (imageWidth === '50%') {
873
+ img.style.width = '50%';
874
+ img.style.height = 'auto';
875
+ }
876
+ // '원본'인 경우 스타일을 설정하지 않음
877
+
878
+ // 컨테이너 div 생성 (정렬용)
879
+ const container = document.createElement('div');
880
+ container.style.textAlign = imageAlign;
881
+ container.appendChild(img);
882
+
883
+ // 에디터에 포커스 먼저 설정
884
+ if (editorRef.current) {
885
+ editorRef.current.focus();
886
+
887
+ const selection = window.getSelection();
888
+
889
+ // 저장된 선택 영역이 있으면 복원
890
+ if (savedImageSelection && selection) {
891
+ try {
892
+ selection.removeAllRanges();
893
+ selection.addRange(savedImageSelection);
894
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
895
+ } catch (e) {
896
+ // 복원 실패 시 무시
897
+ }
898
+ }
899
+
900
+ // 선택 영역 재확인
901
+ if (!selection || selection.rangeCount === 0 || !editorRef.current.contains(selection.anchorNode)) {
902
+ // 에디터가 비어있으면 p 태그 추가
903
+ if (!editorRef.current.innerHTML || editorRef.current.innerHTML === '<br>') {
904
+ const p = document.createElement('p');
905
+ p.innerHTML = '<br>';
906
+ editorRef.current.appendChild(p);
907
+ }
908
+
909
+ // 커서를 에디터 끝으로 이동
910
+ const range = document.createRange();
911
+ range.selectNodeContents(editorRef.current);
912
+ range.collapse(false);
913
+ selection?.removeAllRanges();
914
+ selection?.addRange(range);
915
+ }
916
+
917
+ // 이제 이미지 삽입
918
+ if (selection && selection.rangeCount > 0) {
919
+ const range = selection.getRangeAt(0);
920
+ range.deleteContents();
921
+ range.insertNode(container);
922
+
923
+ // 이미지 다음에 새 문단 추가
924
+ const newP = document.createElement('p');
925
+ newP.innerHTML = '<br>';
926
+ container.after(newP);
927
+
928
+ // 커서를 새 문단으로 이동
929
+ const newRange = document.createRange();
930
+ newRange.selectNodeContents(newP);
931
+ newRange.collapse(true);
932
+ selection.removeAllRanges();
933
+ selection.addRange(newRange);
934
+
935
+ } else {
936
+ // 폴백: 에디터 끝에 추가
937
+ editorRef.current.appendChild(container);
938
+ }
939
+ }
940
+
941
+
942
+
943
+ // 상태 초기화
944
+ setIsImageDropdownOpen(false);
945
+ setImageTabMode('file'); // 탭 모드도 초기화
946
+ setImageUrl('');
947
+ setImageFile(null);
948
+ setImagePreview('');
949
+ setImageWidth('original'); // 원본으로 초기화
950
+ setImageAlign('left'); // 좌측으로 초기화
951
+ setImageAlt('');
952
+ setSavedImageSelection(null); // 저장된 선택 영역 초기화
953
+
954
+ editorRef.current?.focus();
955
+ handleInput();
956
+ };
957
+
958
+ // YouTube URL에서 Video ID 추출
959
+ const extractYoutubeVideoId = (url: string): string | null => {
960
+ // 다양한 YouTube URL 형식 지원
961
+ const patterns = [
962
+ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
963
+ /youtube\.com\/watch\?.*v=([^&\n?#]+)/,
964
+ ];
965
+
966
+ for (const pattern of patterns) {
967
+ const match = url.match(pattern);
968
+ if (match && match[1]) {
969
+ return match[1];
970
+ }
971
+ }
972
+ return null;
973
+ };
974
+
975
+ // YouTube 선택
976
+ const selectYoutube = (youtubeContainer: HTMLElement) => {
977
+ // 기존 선택 해제
978
+ if (selectedYoutube) {
979
+ deselectYoutube();
980
+ }
981
+ if (selectedImage) {
982
+ deselectImage();
983
+ }
984
+
985
+ setSelectedYoutube(youtubeContainer);
986
+
987
+ // 유튜브 주위에 wrapper 추가
988
+ const wrapper = document.createElement('div');
989
+ wrapper.className = 'youtube-wrapper';
990
+ wrapper.style.position = 'relative';
991
+ wrapper.style.border = '2px solid #0084ff';
992
+ wrapper.style.padding = '0';
993
+
994
+ // wrapper를 유튜브 컨테이너와 동일한 display 속성으로 설정
995
+ const computedStyle = window.getComputedStyle(youtubeContainer);
996
+ wrapper.style.display = computedStyle.display;
997
+ // style.width가 있으면 그것을 사용, 없으면 computed width 사용
998
+ wrapper.style.width = youtubeContainer.style.width || computedStyle.width;
999
+
1000
+ // 원본 스타일 저장 (나중에 복원용)
1001
+ youtubeContainer.dataset.originalWidth = youtubeContainer.style.width;
1002
+ youtubeContainer.dataset.originalDisplay = youtubeContainer.style.display;
1003
+
1004
+ // 리사이즈 핸들 추가 (8개 포인트)
1005
+ const handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
1006
+ handles.forEach(handle => {
1007
+ const handleDiv = document.createElement('div');
1008
+ handleDiv.className = `resize-handle resize-handle-${handle}`;
1009
+ handleDiv.dataset.handle = handle;
1010
+ handleDiv.style.position = 'absolute';
1011
+ handleDiv.style.width = '8px';
1012
+ handleDiv.style.height = '8px';
1013
+ handleDiv.style.backgroundColor = '#0084ff';
1014
+ handleDiv.style.border = '1px solid white';
1015
+ handleDiv.style.borderRadius = '2px';
1016
+ handleDiv.style.cursor = `${handle}-resize`;
1017
+
1018
+ // 핸들 위치 설정
1019
+ switch(handle) {
1020
+ case 'nw': handleDiv.style.top = '-5px'; handleDiv.style.left = '-5px'; break;
1021
+ case 'n': handleDiv.style.top = '-5px'; handleDiv.style.left = '50%'; handleDiv.style.transform = 'translateX(-50%)'; break;
1022
+ case 'ne': handleDiv.style.top = '-5px'; handleDiv.style.right = '-5px'; break;
1023
+ case 'e': handleDiv.style.top = '50%'; handleDiv.style.right = '-5px'; handleDiv.style.transform = 'translateY(-50%)'; break;
1024
+ case 'se': handleDiv.style.bottom = '-5px'; handleDiv.style.right = '-5px'; break;
1025
+ case 's': handleDiv.style.bottom = '-5px'; handleDiv.style.left = '50%'; handleDiv.style.transform = 'translateX(-50%)'; break;
1026
+ case 'sw': handleDiv.style.bottom = '-5px'; handleDiv.style.left = '-5px'; break;
1027
+ case 'w': handleDiv.style.top = '50%'; handleDiv.style.left = '-5px'; handleDiv.style.transform = 'translateY(-50%)'; break;
1028
+ }
1029
+
1030
+ // 리사이즈 이벤트 핸들러
1031
+ handleDiv.onmousedown = (e) => {
1032
+ e.preventDefault();
1033
+ e.stopPropagation();
1034
+ startYoutubeResize(e, youtubeContainer, handle);
1035
+ };
1036
+
1037
+ wrapper.appendChild(handleDiv);
1038
+ });
1039
+
1040
+ // 유튜브를 wrapper로 감싸기
1041
+ const parent = youtubeContainer.parentNode;
1042
+ parent?.insertBefore(wrapper, youtubeContainer);
1043
+ wrapper.appendChild(youtubeContainer);
1044
+
1045
+ // 편집 팝업 데이터 설정
1046
+ // 컨테이너 크기 확인
1047
+ if (youtubeContainer.style.width) {
1048
+ if (youtubeContainer.style.width === '560px') {
1049
+ setEditYoutubeWidth('original');
1050
+ } else if (youtubeContainer.style.width.includes('%')) {
1051
+ setEditYoutubeWidth(youtubeContainer.style.width);
1052
+ } else {
1053
+ // px 값을 %로 변환
1054
+ const parentWidth = editorRef.current?.offsetWidth || window.innerWidth;
1055
+ const containerWidth = parseInt(youtubeContainer.style.width);
1056
+ const percentage = Math.round((containerWidth / parentWidth) * 100);
1057
+
1058
+ if (percentage >= 95) {
1059
+ setEditYoutubeWidth('100%');
1060
+ } else if (percentage >= 70 && percentage <= 80) {
1061
+ setEditYoutubeWidth('75%');
1062
+ } else if (percentage >= 45 && percentage <= 55) {
1063
+ setEditYoutubeWidth('50%');
1064
+ } else {
1065
+ setEditYoutubeWidth(`${percentage}%`);
1066
+ }
1067
+ }
1068
+ } else {
1069
+ setEditYoutubeWidth('100%');
1070
+ }
1071
+
1072
+ // 정렬 확인
1073
+ let alignContainer = youtubeContainer.parentElement;
1074
+ let currentAlign = 'center';
1075
+ while (alignContainer && alignContainer !== editorRef.current) {
1076
+ if (alignContainer.tagName === 'DIV' && alignContainer.style.textAlign) {
1077
+ currentAlign = alignContainer.style.textAlign;
1078
+ break;
1079
+ }
1080
+ alignContainer = alignContainer.parentElement;
1081
+ }
1082
+ setEditYoutubeAlign(currentAlign);
1083
+
1084
+ // 약간의 지연 후 편집창 열기
1085
+ setTimeout(() => {
1086
+ setIsYoutubeEditPopupOpen(true);
1087
+ }, 50);
1088
+ };
1089
+
1090
+ // YouTube 선택 해제
1091
+ const deselectYoutube = () => {
1092
+ if (!selectedYoutube) return;
1093
+
1094
+ // 원본 스타일 복원
1095
+ if (selectedYoutube.dataset.originalWidth !== undefined) {
1096
+ selectedYoutube.style.width = selectedYoutube.dataset.originalWidth;
1097
+ delete selectedYoutube.dataset.originalWidth;
1098
+ }
1099
+ if (selectedYoutube.dataset.originalDisplay !== undefined) {
1100
+ selectedYoutube.style.display = selectedYoutube.dataset.originalDisplay;
1101
+ delete selectedYoutube.dataset.originalDisplay;
1102
+ }
1103
+
1104
+ // wrapper 제거
1105
+ const wrapper = selectedYoutube.parentElement;
1106
+ if (wrapper && wrapper.classList.contains('youtube-wrapper')) {
1107
+ const parent = wrapper.parentNode;
1108
+ if (parent) {
1109
+ try {
1110
+ parent.insertBefore(selectedYoutube, wrapper);
1111
+ wrapper.remove();
1112
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1113
+ } catch (e) {
1114
+ // 이미 제거된 경우 무시
1115
+ }
1116
+ }
1117
+ }
1118
+
1119
+ // 상태 초기화
1120
+ setSelectedYoutube(null);
1121
+ setIsYoutubeEditPopupOpen(false);
1122
+ };
1123
+
1124
+ // YouTube 리사이즈 시작
1125
+ const startYoutubeResize = (e: MouseEvent, container: HTMLElement, handle: string) => {
1126
+ // 컨테이너의 실제 크기를 가져옴 (getBoundingClientRect로 실제 픽셀 크기 가져오기)
1127
+ const rect = container.getBoundingClientRect();
1128
+ const currentWidth = rect.width;
1129
+ const currentHeight = rect.height || (currentWidth / (16/9)); // height가 없으면 16:9 비율로 계산
1130
+
1131
+ setIsResizing(true);
1132
+ setResizeStartData({
1133
+ startX: e.clientX,
1134
+ startY: e.clientY,
1135
+ startWidth: currentWidth,
1136
+ startHeight: currentHeight,
1137
+ handle
1138
+ });
1139
+ };
1140
+
1141
+ // YouTube 편집 적용
1142
+ const applyYoutubeEdit = () => {
1143
+ if (!selectedYoutube) return;
1144
+
1145
+ // 크기 적용
1146
+ if (editYoutubeWidth === '100%' || editYoutubeWidth === '75%' || editYoutubeWidth === '50%') {
1147
+ // 퍼센트 값은 그대로 유지
1148
+ selectedYoutube.style.width = editYoutubeWidth;
1149
+ selectedYoutube.style.aspectRatio = '16 / 9';
1150
+ selectedYoutube.style.height = 'auto';
1151
+ } else if (editYoutubeWidth === 'original') {
1152
+ // original은 고정 크기
1153
+ selectedYoutube.style.aspectRatio = '';
1154
+ selectedYoutube.style.width = '560px';
1155
+ selectedYoutube.style.height = '315px';
1156
+ } else {
1157
+ // px 값은 그대로 설정 (리사이즈로 변경된 경우)
1158
+ selectedYoutube.style.aspectRatio = '';
1159
+ selectedYoutube.style.width = editYoutubeWidth;
1160
+ // height 계산
1161
+ const width = parseInt(editYoutubeWidth);
1162
+ const height = width / (16 / 9);
1163
+ selectedYoutube.style.height = height + 'px';
1164
+ }
1165
+
1166
+ // wrapper 크기도 업데이트
1167
+ const wrapper = selectedYoutube.parentElement;
1168
+ if (wrapper && wrapper.classList.contains('youtube-wrapper')) {
1169
+ wrapper.style.width = selectedYoutube.style.width;
1170
+ wrapper.style.aspectRatio = selectedYoutube.style.aspectRatio;
1171
+ if (selectedYoutube.style.height && selectedYoutube.style.height !== 'auto') {
1172
+ wrapper.style.height = selectedYoutube.style.height;
1173
+ } else {
1174
+ wrapper.style.height = 'auto';
1175
+ }
1176
+ }
1177
+
1178
+ // dataset의 originalWidth도 업데이트 (선택 해제 시 이 크기로 복원)
1179
+ selectedYoutube.dataset.originalWidth = selectedYoutube.style.width;
1180
+
1181
+ // 정렬 적용
1182
+ // youtube-wrapper의 부모를 찾음
1183
+ const targetElement = selectedYoutube.parentElement?.classList.contains('youtube-wrapper')
1184
+ ? selectedYoutube.parentElement
1185
+ : selectedYoutube;
1186
+
1187
+ // 정렬 컨테이너 찾기 (최상위 DIV 컨테이너)
1188
+ const alignContainer = targetElement?.parentElement;
1189
+
1190
+ // 정렬 컨테이너가 있고 DIV이면 정렬 적용
1191
+ if (alignContainer && alignContainer.tagName === 'DIV' && alignContainer !== editorRef.current) {
1192
+ alignContainer.style.textAlign = editYoutubeAlign;
1193
+
1194
+ // 유튜브 컨테이너 자체도 적절한 display 설정
1195
+ if (editYoutubeAlign === 'center' || editYoutubeAlign === 'right') {
1196
+ selectedYoutube.style.display = 'inline-block';
1197
+ } else {
1198
+ selectedYoutube.style.display = 'inline-block';
1199
+ }
1200
+ }
1201
+
1202
+ // 선택 해제
1203
+ deselectYoutube();
1204
+ handleInput();
1205
+ };
1206
+
1207
+ // YouTube 삭제
1208
+ const deleteYoutube = () => {
1209
+ if (!selectedYoutube) return;
1210
+
1211
+ const youtubeToDelete = selectedYoutube;
1212
+ deselectYoutube();
1213
+
1214
+ let elementToRemove: HTMLElement = youtubeToDelete;
1215
+ let parent = youtubeToDelete.parentElement;
1216
+
1217
+ while (parent && parent !== editorRef.current) {
1218
+ if (parent.classList.contains('youtube-wrapper') ||
1219
+ parent.classList.contains('youtube-container') ||
1220
+ (parent.tagName === 'DIV' && parent.style.textAlign)) {
1221
+ elementToRemove = parent;
1222
+ parent = parent.parentElement;
1223
+ } else {
1224
+ break;
1225
+ }
1226
+ }
1227
+
1228
+ if (elementToRemove.parentNode) {
1229
+ elementToRemove.parentNode.removeChild(elementToRemove);
1230
+ }
1231
+
1232
+ handleInput();
1233
+ };
1234
+
1235
+ // YouTube 삽입
1236
+ const insertYoutube = () => {
1237
+ if (!youtubeUrl) return;
1238
+
1239
+ const videoId = extractYoutubeVideoId(youtubeUrl);
1240
+ if (!videoId) {
1241
+ alert('올바른 유튜브 URL을 입력해주세요.\n\n지원 형식:\n• https://www.youtube.com/watch?v=VIDEO_ID\n• https://youtu.be/VIDEO_ID');
1242
+ return;
1243
+ }
1244
+
1245
+ // YouTube 정렬 컨테이너 생성
1246
+ const alignContainer = document.createElement('div');
1247
+ alignContainer.style.textAlign = youtubeAlign;
1248
+ alignContainer.style.margin = '20px 0';
1249
+
1250
+ // YouTube iframe 컨테이너 생성
1251
+ const container = document.createElement('div');
1252
+ container.className = 'youtube-container';
1253
+ container.style.position = 'relative';
1254
+ container.style.display = 'inline-block';
1255
+ container.style.maxWidth = '100%';
1256
+
1257
+ // 크기 설정
1258
+ if (youtubeWidth === 'original') {
1259
+ container.style.width = '560px';
1260
+ container.style.height = '315px';
1261
+ } else if (youtubeWidth === '100%' || youtubeWidth === '75%' || youtubeWidth === '50%') {
1262
+ // 퍼센트는 그대로 유지하고 aspect-ratio 사용
1263
+ container.style.width = youtubeWidth;
1264
+ container.style.aspectRatio = '16 / 9';
1265
+ } else {
1266
+ // 기타 값은 그대로 설정
1267
+ container.style.width = youtubeWidth;
1268
+ container.style.aspectRatio = '16 / 9';
1269
+ }
1270
+
1271
+ // iframe 생성
1272
+ const iframe = document.createElement('iframe');
1273
+ iframe.width = '100%';
1274
+ iframe.height = '100%';
1275
+ iframe.src = `https://www.youtube.com/embed/${videoId}`;
1276
+ iframe.title = 'YouTube video player';
1277
+ iframe.frameBorder = '0';
1278
+ iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
1279
+ iframe.allowFullscreen = true;
1280
+ iframe.style.width = '100%';
1281
+ iframe.style.height = 'auto';
1282
+ iframe.style.aspectRatio = '16 / 9';
1283
+ iframe.style.display = 'block';
1284
+
1285
+ // 투명 오버레이 추가 (편집 모드에서 클릭 방지)
1286
+ const overlay = document.createElement('div');
1287
+ overlay.className = 'youtube-overlay';
1288
+ overlay.style.position = 'absolute';
1289
+ overlay.style.top = '0';
1290
+ overlay.style.left = '0';
1291
+ overlay.style.width = '100%';
1292
+ overlay.style.height = '100%';
1293
+ overlay.style.backgroundColor = 'transparent';
1294
+ overlay.style.cursor = 'pointer';
1295
+ overlay.style.zIndex = '1';
1296
+
1297
+ container.appendChild(iframe);
1298
+ container.appendChild(overlay);
1299
+ alignContainer.appendChild(container);
1300
+
1301
+ // 에디터에 포커스 설정
1302
+ if (editorRef.current) {
1303
+ editorRef.current.focus();
1304
+
1305
+ const selection = window.getSelection();
1306
+
1307
+ // 저장된 선택 영역이 있으면 복원
1308
+ if (savedYoutubeSelection && selection) {
1309
+ try {
1310
+ selection.removeAllRanges();
1311
+ selection.addRange(savedYoutubeSelection);
1312
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1313
+ } catch (e) {
1314
+ // 선택 영역 복원 실패 시 무시
1315
+ }
1316
+ }
1317
+
1318
+ // 선택 영역 재확인
1319
+ if (!selection || selection.rangeCount === 0 || !editorRef.current.contains(selection.anchorNode)) {
1320
+ // 에디터가 비어있으면 p 태그 추가
1321
+ if (!editorRef.current.innerHTML || editorRef.current.innerHTML === '<br>') {
1322
+ const p = document.createElement('p');
1323
+ p.innerHTML = '<br>';
1324
+ editorRef.current.appendChild(p);
1325
+ }
1326
+
1327
+ // 커서를 에디터 끝으로 이동
1328
+ const range = document.createRange();
1329
+ range.selectNodeContents(editorRef.current);
1330
+ range.collapse(false);
1331
+ selection?.removeAllRanges();
1332
+ selection?.addRange(range);
1333
+ }
1334
+
1335
+ // YouTube iframe 삽입
1336
+ if (selection && selection.rangeCount > 0) {
1337
+ const range = selection.getRangeAt(0);
1338
+ range.deleteContents();
1339
+ range.insertNode(alignContainer);
1340
+
1341
+ // iframe 다음에 새 문단 추가
1342
+ const newP = document.createElement('p');
1343
+ newP.innerHTML = '<br>';
1344
+ alignContainer.after(newP);
1345
+
1346
+ // 커서를 새 문단으로 이동
1347
+ const newRange = document.createRange();
1348
+ newRange.selectNodeContents(newP);
1349
+ newRange.collapse(true);
1350
+ selection.removeAllRanges();
1351
+ selection.addRange(newRange);
1352
+ } else {
1353
+ // 폴백: 에디터 끝에 추가
1354
+ editorRef.current.appendChild(alignContainer);
1355
+ }
1356
+ }
1357
+
1358
+ // 상태 초기화
1359
+ setIsYoutubeDropdownOpen(false);
1360
+ setYoutubeUrl('');
1361
+ setYoutubeWidth('100%'); // 초기화
1362
+ setYoutubeAlign('center'); // 초기화
1363
+ setSavedYoutubeSelection(null);
1364
+
1365
+ editorRef.current?.focus();
1366
+ handleInput();
1367
+ };
1368
+
1369
+ const handleImageFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
1370
+ const file = e.target.files?.[0];
1371
+ if (file && file.type.startsWith('image/')) {
1372
+ setImageFile(file);
1373
+
1374
+ const reader = new FileReader();
1375
+ reader.onloadend = () => {
1376
+ const base64String = reader.result as string;
1377
+ setImagePreview(base64String);
1378
+ // URL 필드 초기화
1379
+ setImageUrl('');
1380
+ };
1381
+ reader.readAsDataURL(file);
1382
+ } else {
1383
+ alert('이미지 파일을 선택해주세요.');
1384
+ }
1385
+ // 같은 파일을 다시 선택할 수 있도록 초기화
1386
+ e.target.value = '';
1387
+ };
1388
+
1389
+ // 기존 handleFileUpload은 삭제 또는 제거 예정
1390
+ const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
1391
+ const file = e.target.files?.[0];
1392
+ if (file && file.type.startsWith('image/')) {
1393
+ const reader = new FileReader();
1394
+ reader.onloadend = () => {
1395
+ const base64String = reader.result as string;
1396
+ execCommand('insertImage', base64String);
1397
+ };
1398
+ reader.readAsDataURL(file);
1399
+ } else {
1400
+ alert('이미지 파일을 선택해주세요.');
1401
+ }
1402
+ // 같은 파일을 다시 선택할 수 있도록 초기화
1403
+ e.target.value = '';
1404
+ };
1405
+
1406
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
1407
+ // Backspace 또는 Delete 키로 선택된 이미지 삭제
1408
+ if ((e.key === 'Backspace' || e.key === 'Delete') && selectedImage) {
1409
+ e.preventDefault();
1410
+
1411
+ // deleteImage 함수 호출로 통합
1412
+ deleteImage();
1413
+ return;
1414
+ }
1415
+
1416
+ // Backspace 또는 Delete 키로 선택된 유튜브 삭제
1417
+ if ((e.key === 'Backspace' || e.key === 'Delete') && selectedYoutube) {
1418
+ e.preventDefault();
1419
+ deleteYoutube();
1420
+ return;
1421
+ }
1422
+
1423
+ // 에디터가 비어있고 처음 입력하는 경우
1424
+ if (editorRef.current && (!editorRef.current.innerHTML || editorRef.current.innerHTML === '<br>')) {
1425
+ // Enter, Backspace, Delete가 아닌 일반 문자 입력인 경우
1426
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
1427
+ e.preventDefault();
1428
+
1429
+ // p 태그 생성 및 텍스트 삽입
1430
+ const p = document.createElement('p');
1431
+ p.textContent = e.key;
1432
+ editorRef.current.innerHTML = '';
1433
+ editorRef.current.appendChild(p);
1434
+
1435
+ // 커서를 텍스트 끝으로 이동
1436
+ const selection = window.getSelection();
1437
+ const range = document.createRange();
1438
+ range.selectNodeContents(p);
1439
+ range.collapse(false);
1440
+ selection?.removeAllRanges();
1441
+ selection?.addRange(range);
1442
+
1443
+ handleInput();
1444
+ return;
1445
+ }
1446
+ }
1447
+
1448
+ if (e.key === 'Enter' && !e.shiftKey) {
1449
+ // Enter만 눌렀을 때: p 태그로 새 문단 생성
1450
+ e.preventDefault();
1451
+
1452
+ // insertParagraph를 사용하여 새 문단 생성
1453
+ document.execCommand('insertParagraph', false);
1454
+
1455
+ // 새로 생성된 문단을 p 태그로 변환
1456
+ setTimeout(() => {
1457
+ const selection = window.getSelection();
1458
+ if (selection && selection.rangeCount > 0) {
1459
+ const range = selection.getRangeAt(0);
1460
+ let container = range.commonAncestorContainer;
1461
+
1462
+ // 텍스트 노드인 경우 부모 요소로
1463
+ if (container.nodeType === Node.TEXT_NODE) {
1464
+ container = container.parentElement as Node;
1465
+ }
1466
+
1467
+ // div인 경우 p로 변경
1468
+ if (container && (container as HTMLElement).tagName === 'DIV') {
1469
+ document.execCommand('formatBlock', false, 'p');
1470
+ }
1471
+ }
1472
+ handleInput();
1473
+ }, 0);
1474
+ }
1475
+ // Shift+Enter는 브라우저 기본 동작 사용 (br 태그 삽입)
1476
+ };
1477
+
1478
+ // 선택 영역 저장
1479
+ const saveSelection = () => {
1480
+ const selection = window.getSelection();
1481
+ if (selection && selection.rangeCount > 0) {
1482
+ return selection.getRangeAt(0);
1483
+ }
1484
+ return null;
1485
+ };
1486
+
1487
+ // 선택 영역 복원
1488
+ const restoreSelection = (range: Range | null) => {
1489
+ if (range) {
1490
+ const selection = window.getSelection();
1491
+ if (selection) {
1492
+ selection.removeAllRanges();
1493
+ selection.addRange(range);
1494
+ }
1495
+ }
1496
+ };
1497
+
1498
+ const applyColorStyle = (styleProperty: string, color: string, savedRange?: Range | null) => {
1499
+ // 저장된 선택 영역이 있으면 복원
1500
+ if (savedRange) {
1501
+ restoreSelection(savedRange);
1502
+ }
1503
+
1504
+ const selection = window.getSelection();
1505
+ if (!selection || selection.isCollapsed) {
1506
+ return;
1507
+ }
1508
+
1509
+ const range = selection.getRangeAt(0);
1510
+
1511
+ // 선택된 텍스트를 span으로 감싸기
1512
+ const span = document.createElement('span');
1513
+
1514
+ try {
1515
+ const contents = range.extractContents();
1516
+
1517
+ // 스타일 적용 - setAttribute를 사용하여 !important 포함
1518
+ if (styleProperty === 'color') {
1519
+ span.setAttribute('style', `color: ${color} !important;`);
1520
+ } else if (styleProperty === 'background-color') {
1521
+ span.setAttribute('style', `background-color: ${color} !important;`);
1522
+ }
1523
+
1524
+ span.appendChild(contents);
1525
+ range.insertNode(span);
1526
+
1527
+ // 커서 위치 조정
1528
+ range.selectNodeContents(span);
1529
+ range.collapse(false);
1530
+ selection.removeAllRanges();
1531
+ selection.addRange(range);
1532
+
1533
+ } catch {
1534
+ // 폴백: execCommand 사용
1535
+ if (styleProperty === 'color') {
1536
+ document.execCommand('foreColor', false, color);
1537
+ } else {
1538
+ document.execCommand('hiliteColor', false, color);
1539
+ }
1540
+ }
1541
+
1542
+ editorRef.current?.focus();
1543
+ handleInput();
1544
+ };
1545
+
1546
+ const changeFontColor = (color: string, savedRange?: Range | null) => {
1547
+ applyColorStyle('color', color, savedRange);
1548
+ };
1549
+
1550
+ const changeBackgroundColor = (color: string, savedRange?: Range | null) => {
1551
+ applyColorStyle('background-color', color, savedRange);
1552
+ };
1553
+
1554
+ // 클라이언트에서만 고유 ID 생성
1555
+ useEffect(() => {
1556
+ setEditorID(`editor-${uuid()}`);
1557
+ }, []);
1558
+
1559
+ useEffect(() => {
1560
+ if (editorRef.current && value && !editorRef.current.innerHTML) {
1561
+ editorRef.current.innerHTML = value;
1562
+ }
1563
+ }, [value]);
1564
+
1565
+ // 외부 클릭 시 드롭다운 닫기
1566
+ useEffect(() => {
1567
+ const handleClickOutside = (event: MouseEvent) => {
1568
+ const target = event.target as Node;
1569
+
1570
+ if (paragraphButtonRef.current && !paragraphButtonRef.current.contains(target)) {
1571
+ setIsParagraphDropdownOpen(false);
1572
+ }
1573
+
1574
+ if (textColorButtonRef.current && !textColorButtonRef.current.contains(target)) {
1575
+ setIsTextColorOpen(false);
1576
+ }
1577
+
1578
+ if (bgColorButtonRef.current && !bgColorButtonRef.current.contains(target)) {
1579
+ setIsBgColorOpen(false);
1580
+ }
1581
+
1582
+ if (alignButtonRef.current && !alignButtonRef.current.contains(target)) {
1583
+ setIsAlignDropdownOpen(false);
1584
+ }
1585
+
1586
+ if (linkButtonRef.current && !linkButtonRef.current.contains(target)) {
1587
+ setIsLinkDropdownOpen(false);
1588
+ setLinkUrl('');
1589
+ setLinkTarget('_blank');
1590
+ setSavedSelection(null);
1591
+ }
1592
+
1593
+ // 이미지 드롭다운 체크 - 드롭다운 자체도 체크
1594
+ const imageDropdown = document.querySelector(`.${styles.imageDropdown}`);
1595
+ if (imageButtonRef.current &&
1596
+ !imageButtonRef.current.contains(target) &&
1597
+ (!imageDropdown || !imageDropdown.contains(target))) {
1598
+ setIsImageDropdownOpen(false);
1599
+ setImageTabMode('file'); // 탭 모드 초기화
1600
+ setImageUrl('');
1601
+ setImageFile(null);
1602
+ setImagePreview('');
1603
+ setImageWidth('original'); // 원본으로 초기화
1604
+ setImageAlign('left'); // 좌측으로 초기화
1605
+ setImageAlt('');
1606
+ setSavedImageSelection(null); // 저장된 선택 영역 초기화
1607
+ }
1608
+
1609
+ // 유튜브 드롭다운 체크
1610
+ const youtubeDropdown = document.querySelector(`.${styles.youtubeDropdown}`);
1611
+ if (youtubeButtonRef.current &&
1612
+ !youtubeButtonRef.current.contains(target) &&
1613
+ (!youtubeDropdown || !youtubeDropdown.contains(target))) {
1614
+ setIsYoutubeDropdownOpen(false);
1615
+ setYoutubeUrl('');
1616
+ setSavedYoutubeSelection(null);
1617
+ }
1618
+
1619
+ // 이미지 편집 팝업 닫기
1620
+ if (isImageEditPopupOpen && selectedImage) {
1621
+ const imageEditPopup = document.querySelector(`.${styles.imageDropdown}`);
1622
+ // 편집 팝업, 선택된 이미지, image-wrapper 외부를 클릭한 경우
1623
+ if (imageEditPopup &&
1624
+ !imageEditPopup.contains(target) &&
1625
+ !selectedImage.contains(target) &&
1626
+ !selectedImage.parentElement?.contains(target)) {
1627
+ setIsImageEditPopupOpen(false);
1628
+ }
1629
+ }
1630
+
1631
+ // 링크 수정 팝업 닫기
1632
+ if (isEditLinkPopupOpen) {
1633
+ const editPopup = document.querySelector(`.${styles.editLinkPopup}`);
1634
+ if (editPopup && !editPopup.contains(target) && !selectedLinkElement?.contains(target)) {
1635
+ setIsEditLinkPopupOpen(false);
1636
+ setSelectedLinkElement(null);
1637
+ setEditLinkUrl('');
1638
+ setEditLinkTarget('_self');
1639
+ }
1640
+ }
1641
+ };
1642
+
1643
+ if (isParagraphDropdownOpen || isTextColorOpen || isBgColorOpen || isAlignDropdownOpen || isLinkDropdownOpen || isEditLinkPopupOpen || isImageDropdownOpen || isImageEditPopupOpen || isYoutubeDropdownOpen) {
1644
+ document.addEventListener('mousedown', handleClickOutside);
1645
+ }
1646
+
1647
+ return () => {
1648
+ document.removeEventListener('mousedown', handleClickOutside);
1649
+ };
1650
+ }, [isParagraphDropdownOpen, isTextColorOpen, isBgColorOpen, isAlignDropdownOpen, isLinkDropdownOpen, isEditLinkPopupOpen, isImageDropdownOpen, isImageEditPopupOpen, isYoutubeDropdownOpen, selectedLinkElement, selectedImage]);
1651
+
1652
+ // 리사이즈 중 마우스 이벤트 처리
1653
+ useEffect(() => {
1654
+ if (!isResizing || !resizeStartData) return;
1655
+
1656
+ const handleMouseMove = (e: MouseEvent) => {
1657
+ if (!resizeStartData) return;
1658
+
1659
+ const deltaX = e.clientX - resizeStartData.startX;
1660
+ const deltaY = e.clientY - resizeStartData.startY;
1661
+
1662
+ // 이미지 리사이즈
1663
+ if (selectedImage) {
1664
+ const aspectRatio = resizeStartData.startWidth / resizeStartData.startHeight;
1665
+ let newWidth = resizeStartData.startWidth;
1666
+ let newHeight = resizeStartData.startHeight;
1667
+
1668
+ switch (resizeStartData.handle) {
1669
+ case 'e':
1670
+ case 'w':
1671
+ newWidth = resizeStartData.startWidth + (resizeStartData.handle === 'e' ? deltaX : -deltaX);
1672
+ newHeight = newWidth / aspectRatio;
1673
+ break;
1674
+ case 'n':
1675
+ case 's':
1676
+ newHeight = resizeStartData.startHeight + (resizeStartData.handle === 's' ? deltaY : -deltaY);
1677
+ newWidth = newHeight * aspectRatio;
1678
+ break;
1679
+ case 'ne':
1680
+ case 'nw':
1681
+ case 'se':
1682
+ case 'sw': {
1683
+ // 대각선 리사이즈는 더 큰 변화량 기준
1684
+ const diagonalDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
1685
+ const multiplier = resizeStartData.handle.includes('e') ? 1 : -1;
1686
+ newWidth = resizeStartData.startWidth + (diagonalDelta * multiplier);
1687
+ newHeight = newWidth / aspectRatio;
1688
+ break;
1689
+ }
1690
+ }
1691
+
1692
+ // 최소 크기 제한
1693
+ newWidth = Math.max(50, newWidth);
1694
+ newHeight = Math.max(50, newHeight);
1695
+
1696
+ selectedImage.style.width = newWidth + 'px';
1697
+ selectedImage.style.height = newHeight + 'px';
1698
+ }
1699
+
1700
+ // 유튜브 리사이즈
1701
+ if (selectedYoutube) {
1702
+ const aspectRatio = 16 / 9; // 유튜브는 16:9 고정
1703
+ let newWidth = resizeStartData.startWidth;
1704
+ let newHeight = resizeStartData.startHeight;
1705
+
1706
+ switch (resizeStartData.handle) {
1707
+ case 'e':
1708
+ case 'w':
1709
+ newWidth = resizeStartData.startWidth + (resizeStartData.handle === 'e' ? deltaX : -deltaX);
1710
+ newHeight = newWidth / aspectRatio;
1711
+ break;
1712
+ case 'n':
1713
+ case 's':
1714
+ newHeight = resizeStartData.startHeight + (resizeStartData.handle === 's' ? deltaY : -deltaY);
1715
+ newWidth = newHeight * aspectRatio;
1716
+ break;
1717
+ case 'ne':
1718
+ case 'nw':
1719
+ case 'se':
1720
+ case 'sw': {
1721
+ // 대각선 리사이즈는 더 큰 변화량 기준
1722
+ const diagonalDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
1723
+ const multiplier = resizeStartData.handle.includes('e') ? 1 : -1;
1724
+ newWidth = resizeStartData.startWidth + (diagonalDelta * multiplier);
1725
+ newHeight = newWidth / aspectRatio;
1726
+ break;
1727
+ }
1728
+ }
1729
+
1730
+ // 최소/최대 크기 제한
1731
+ const parentWidth = editorRef.current?.offsetWidth || window.innerWidth;
1732
+ newWidth = Math.max(200, Math.min(newWidth, parentWidth - 40));
1733
+ newHeight = newWidth / aspectRatio;
1734
+
1735
+ // 유튜브 컨테이너 크기 업데이트
1736
+ // aspectRatio를 제거하고 명시적인 크기 설정
1737
+ selectedYoutube.style.aspectRatio = '';
1738
+ selectedYoutube.style.width = newWidth + 'px';
1739
+ selectedYoutube.style.height = newHeight + 'px';
1740
+
1741
+ // wrapper 크기도 업데이트
1742
+ const wrapper = selectedYoutube.parentElement;
1743
+ if (wrapper && wrapper.classList.contains('youtube-wrapper')) {
1744
+ wrapper.style.width = newWidth + 'px';
1745
+ wrapper.style.height = newHeight + 'px';
1746
+ }
1747
+
1748
+ // 편집 중인 크기 업데이트
1749
+ const percentage = Math.round((newWidth / parentWidth) * 100);
1750
+ if (percentage >= 95) {
1751
+ setEditYoutubeWidth('100%');
1752
+ } else if (percentage >= 70 && percentage <= 80) {
1753
+ setEditYoutubeWidth('75%');
1754
+ } else if (percentage >= 45 && percentage <= 55) {
1755
+ setEditYoutubeWidth('50%');
1756
+ } else {
1757
+ setEditYoutubeWidth(`${percentage}%`);
1758
+ }
1759
+ }
1760
+ };
1761
+
1762
+ const handleMouseUp = () => {
1763
+ setIsResizing(false);
1764
+ setResizeStartData(null);
1765
+ if (selectedImage) {
1766
+ setEditImageWidth(selectedImage.style.width);
1767
+ }
1768
+ if (selectedYoutube) {
1769
+ // 유튜브 크기는 이미 px로 설정되어 있으므로,
1770
+ // 편집창의 width 값만 업데이트 (실제 DOM은 변경하지 않음)
1771
+ const currentWidth = selectedYoutube.style.width;
1772
+ setEditYoutubeWidth(currentWidth);
1773
+
1774
+ // 변경된 크기를 새로운 원본으로 설정 (선택 해제 시 이 크기로 복원됨)
1775
+ selectedYoutube.dataset.originalWidth = currentWidth;
1776
+ }
1777
+ };
1778
+
1779
+ document.addEventListener('mousemove', handleMouseMove);
1780
+ document.addEventListener('mouseup', handleMouseUp);
1781
+
1782
+ return () => {
1783
+ document.removeEventListener('mousemove', handleMouseMove);
1784
+ document.removeEventListener('mouseup', handleMouseUp);
1785
+ };
1786
+ }, [isResizing, resizeStartData, selectedImage, selectedYoutube]);
1787
+
1788
+ // 스크롤, 리사이즈 및 이미지/유튜브 드래그 시 편집창 숨기기
1789
+ useEffect(() => {
1790
+ if (!selectedImage && !selectedYoutube) return;
1791
+
1792
+ // 스크롤 이벤트 핸들러
1793
+ const handleScroll = () => {
1794
+ if (isImageEditPopupOpen) {
1795
+ setIsImageEditPopupOpen(false);
1796
+ }
1797
+ if (isYoutubeEditPopupOpen) {
1798
+ setIsYoutubeEditPopupOpen(false);
1799
+ }
1800
+ };
1801
+
1802
+ // 리사이즈 이벤트 핸들러
1803
+ const handleResize = () => {
1804
+ if (isImageEditPopupOpen) {
1805
+ setIsImageEditPopupOpen(false);
1806
+ }
1807
+ if (isYoutubeEditPopupOpen) {
1808
+ setIsYoutubeEditPopupOpen(false);
1809
+ }
1810
+ };
1811
+
1812
+ // 드래그 시작 이벤트 핸들러
1813
+ const handleDragStart = (e: DragEvent) => {
1814
+ if (e.target === selectedImage) {
1815
+ setIsImageEditPopupOpen(false);
1816
+ }
1817
+ if (e.target === selectedYoutube) {
1818
+ setIsYoutubeEditPopupOpen(false);
1819
+ }
1820
+ };
1821
+
1822
+ // 드래그 종료 이벤트 핸들러 - 이미지/유튜브 이동 후 wrapper 재적용
1823
+ const handleDragEnd = (e: DragEvent) => {
1824
+ if (e.target === selectedImage) {
1825
+ // 드래그 후에도 선택 상태 유지를 원한다면 여기서 재선택
1826
+ // 아니면 선택 해제
1827
+ deselectImage();
1828
+ }
1829
+ if (e.target === selectedYoutube) {
1830
+ deselectYoutube();
1831
+ }
1832
+ };
1833
+
1834
+ // 이벤트 리스너 등록
1835
+ window.addEventListener('scroll', handleScroll, true);
1836
+ window.addEventListener('resize', handleResize);
1837
+ editorRef.current?.addEventListener('scroll', handleScroll);
1838
+ containerRef.current?.addEventListener('resize', handleResize);
1839
+
1840
+ if (selectedImage) {
1841
+ selectedImage.addEventListener('dragstart', handleDragStart);
1842
+ selectedImage.addEventListener('dragend', handleDragEnd);
1843
+ // 이미지에 draggable 속성 추가
1844
+ selectedImage.draggable = true;
1845
+ }
1846
+
1847
+ if (selectedYoutube) {
1848
+ selectedYoutube.addEventListener('dragstart', handleDragStart);
1849
+ selectedYoutube.addEventListener('dragend', handleDragEnd);
1850
+ // 유튜브에 draggable 속성 추가
1851
+ selectedYoutube.draggable = true;
1852
+ }
1853
+
1854
+ return () => {
1855
+ window.removeEventListener('scroll', handleScroll, true);
1856
+ window.removeEventListener('resize', handleResize);
1857
+ editorRef.current?.removeEventListener('scroll', handleScroll);
1858
+ containerRef.current?.removeEventListener('resize', handleResize);
1859
+ if (selectedImage) {
1860
+ selectedImage.removeEventListener('dragstart', handleDragStart);
1861
+ selectedImage.removeEventListener('dragend', handleDragEnd);
1862
+ selectedImage.draggable = false;
1863
+ }
1864
+ if (selectedYoutube) {
1865
+ selectedYoutube.removeEventListener('dragstart', handleDragStart);
1866
+ selectedYoutube.removeEventListener('dragend', handleDragEnd);
1867
+ selectedYoutube.draggable = false;
1868
+ }
1869
+ };
1870
+ }, [selectedImage, selectedYoutube, isImageEditPopupOpen, isYoutubeEditPopupOpen]);
1871
+
1872
+ // DOM Mutation Observer - 선택된 이미지가 DOM에서 제거되는 것을 감지
1873
+ useEffect(() => {
1874
+ if (!selectedImage || !editorRef.current) return;
1875
+
1876
+ const observer = new MutationObserver((mutations) => {
1877
+ mutations.forEach((mutation) => {
1878
+ // 제거된 노드들 확인
1879
+ mutation.removedNodes.forEach((node) => {
1880
+ // 제거된 노드가 선택된 이미지이거나 그것을 포함하는 경우
1881
+ if (node === selectedImage ||
1882
+ (node.nodeType === Node.ELEMENT_NODE &&
1883
+ (node as Element).contains(selectedImage))) {
1884
+ // 선택 상태 해제
1885
+ deselectImage();
1886
+ }
1887
+ });
1888
+ });
1889
+ });
1890
+
1891
+ // 에디터 관찰 시작
1892
+ observer.observe(editorRef.current, {
1893
+ childList: true,
1894
+ subtree: true
1895
+ });
1896
+
1897
+ return () => {
1898
+ observer.disconnect();
1899
+ };
1900
+ }, [selectedImage]);
1901
+
1902
+ // DOM Mutation Observer - 선택된 유튜브가 DOM에서 제거되는 것을 감지
1903
+ useEffect(() => {
1904
+ if (!selectedYoutube || !editorRef.current) return;
1905
+
1906
+ const observer = new MutationObserver((mutations) => {
1907
+ mutations.forEach((mutation) => {
1908
+ // 제거된 노드들 확인
1909
+ mutation.removedNodes.forEach((node) => {
1910
+ // 제거된 노드가 선택된 유튜브이거나 그것을 포함하는 경우
1911
+ if (node === selectedYoutube ||
1912
+ (node.nodeType === Node.ELEMENT_NODE &&
1913
+ (node as Element).contains(selectedYoutube))) {
1914
+ // 선택 상태 해제
1915
+ deselectYoutube();
1916
+ }
1917
+ });
1918
+ });
1919
+ });
1920
+
1921
+ // 에디터 관찰 시작
1922
+ observer.observe(editorRef.current, {
1923
+ childList: true,
1924
+ subtree: true
1925
+ });
1926
+
1927
+ return () => {
1928
+ observer.disconnect();
1929
+ };
1930
+ }, [selectedYoutube]);
1931
+
1932
+ // 초기 로드 시 문단 형식 감지 (기본 p 태그는 추가하지 않음)
1933
+ useEffect(() => {
1934
+ // 약간의 지연을 주어 DOM이 완전히 렌더링된 후 감지
1935
+ const timer = setTimeout(() => {
1936
+ if (editorRef.current && editorRef.current.innerHTML) {
1937
+ // 내용이 있을 때만 문단 형식 감지
1938
+ detectCurrentParagraphStyle();
1939
+ detectCurrentAlign();
1940
+ }
1941
+ }, 100);
1942
+
1943
+ return () => clearTimeout(timer);
1944
+ }, []);
1945
+
1946
+ return (
1947
+ <div className={`${styles.editor} ${statusClass}`} style={{ width, position: 'relative' }}>
1948
+ <div className={styles.toolbar}>
1949
+ <div className={styles.toolbarGroup}>
1950
+ <button
1951
+ type="button"
1952
+ className={styles.toolbarButton}
1953
+ onClick={() => execCommand('undo')}
1954
+ title="실행 취소"
1955
+ >
1956
+ <i className={styles.undo} />
1957
+ </button>
1958
+ <button
1959
+ type="button"
1960
+ className={styles.toolbarButton}
1961
+ onClick={() => execCommand('redo')}
1962
+ title="다시 실행"
1963
+ >
1964
+ <i className={styles.redo} />
1965
+ </button>
1966
+ </div>
1967
+
1968
+ <div className={styles.toolbarGroup} ref={paragraphButtonRef}>
1969
+ <button
1970
+ type="button"
1971
+ className={styles.paragraphButton}
1972
+ onClick={() => {
1973
+ setIsParagraphDropdownOpen(!isParagraphDropdownOpen);
1974
+ setIsTextColorOpen(false);
1975
+ setIsBgColorOpen(false);
1976
+ setIsAlignDropdownOpen(false);
1977
+ }}
1978
+ title="문단 형식"
1979
+ >
1980
+ <span>{getCurrentStyleLabel()}</span>
1981
+ <i className={styles.dropdownArrow} />
1982
+ </button>
1983
+
1984
+ {isParagraphDropdownOpen && (
1985
+ <div
1986
+ className={styles.paragraphDropdown}
1987
+ style={{
1988
+ top: paragraphButtonRef.current?.getBoundingClientRect().bottom ?? 0,
1989
+ left: paragraphButtonRef.current?.getBoundingClientRect().left ?? 0
1990
+ }}
1991
+ >
1992
+ {paragraphOptions.map((option) => (
1993
+ <button
1994
+ key={option.value}
1995
+ type="button"
1996
+ className={`${styles.paragraphOption} ${currentParagraphStyle === option.value ? styles.active : ''}`}
1997
+ onClick={() => applyParagraphStyle(option.value)}
1998
+ >
1999
+ {option.value === 'h1' ? (
2000
+ <h1>{option.label}</h1>
2001
+ ) : option.value === 'h2' ? (
2002
+ <h2>{option.label}</h2>
2003
+ ) : option.value === 'h3' ? (
2004
+ <h3>{option.label}</h3>
2005
+ ) : (
2006
+ <span className={option.className || ''}>{option.label}</span>
2007
+ )}
2008
+ </button>
2009
+ ))}
2010
+ </div>
2011
+ )}
2012
+ </div>
2013
+
2014
+ <div className={styles.toolbarGroup}>
2015
+ <button
2016
+ type="button"
2017
+ className={styles.toolbarButton}
2018
+ onClick={() => execCommand('bold')}
2019
+ title="굵게"
2020
+ >
2021
+ <i className={styles.bold} />
2022
+ </button>
2023
+ <button
2024
+ type="button"
2025
+ className={styles.toolbarButton}
2026
+ onClick={() => execCommand('italic')}
2027
+ title="기울임"
2028
+ >
2029
+ <i className={styles.italic} />
2030
+ </button>
2031
+ <button
2032
+ type="button"
2033
+ className={styles.toolbarButton}
2034
+ onClick={() => execCommand('underline')}
2035
+ title="밑줄"
2036
+ >
2037
+ <i className={styles.underline} />
2038
+ </button>
2039
+ <button
2040
+ type="button"
2041
+ className={styles.toolbarButton}
2042
+ onClick={() => execCommand('strikeThrough')}
2043
+ title="취소선"
2044
+ >
2045
+ <i className={styles.strikethrough} />
2046
+ </button>
2047
+ </div>
2048
+
2049
+ <div className={styles.toolbarGroup}>
2050
+ <div ref={textColorButtonRef} style={{ position: 'relative' }}>
2051
+ <button
2052
+ type="button"
2053
+ className={styles.toolbarButton}
2054
+ onClick={() => {
2055
+ const selection = window.getSelection();
2056
+ if (selection && !selection.isCollapsed) {
2057
+ // 선택 영역 저장
2058
+ const range = saveSelection();
2059
+ setSavedSelection(range);
2060
+ setIsTextColorOpen(!isTextColorOpen);
2061
+ setIsBgColorOpen(false);
2062
+ }
2063
+ }}
2064
+ title="글꼴 색상"
2065
+ >
2066
+ <i className={styles.fontColor} />
2067
+ </button>
2068
+ {isTextColorOpen && (
2069
+ <div
2070
+ className={styles.colorPalette}
2071
+ style={{
2072
+ top: textColorButtonRef.current?.getBoundingClientRect().bottom ?? 0,
2073
+ left: textColorButtonRef.current?.getBoundingClientRect().left ?? 0
2074
+ }}
2075
+ >
2076
+ {colorPalette.map((row, rowIndex) => (
2077
+ <div key={rowIndex} className={styles.colorRow}>
2078
+ {row.map((color) => (
2079
+ <button
2080
+ key={color}
2081
+ type="button"
2082
+ className={styles.colorButton}
2083
+ style={{ backgroundColor: color }}
2084
+ onMouseDown={(e) => e.preventDefault()} // 포커스 이동 방지
2085
+ onClick={() => {
2086
+ changeFontColor(color, savedSelection);
2087
+ setIsTextColorOpen(false);
2088
+ setSavedSelection(null);
2089
+ }}
2090
+ />
2091
+ ))}
2092
+ </div>
2093
+ ))}
2094
+ </div>
2095
+ )}
2096
+ </div>
2097
+
2098
+ <div ref={bgColorButtonRef} style={{ position: 'relative' }}>
2099
+ <button
2100
+ type="button"
2101
+ className={styles.toolbarButton}
2102
+ onClick={() => {
2103
+ const selection = window.getSelection();
2104
+ if (selection && !selection.isCollapsed) {
2105
+ // 선택 영역 저장
2106
+ const range = saveSelection();
2107
+ setSavedSelection(range);
2108
+ setIsBgColorOpen(!isBgColorOpen);
2109
+ setIsTextColorOpen(false);
2110
+ }
2111
+ }}
2112
+ title="배경 색상"
2113
+ >
2114
+ <i className={styles.highlight} />
2115
+ </button>
2116
+ {isBgColorOpen && (
2117
+ <div
2118
+ className={styles.colorPalette}
2119
+ style={{
2120
+ top: bgColorButtonRef.current?.getBoundingClientRect().bottom ?? 0,
2121
+ left: bgColorButtonRef.current?.getBoundingClientRect().left ?? 0
2122
+ }}
2123
+ >
2124
+ {colorPalette.map((row, rowIndex) => (
2125
+ <div key={rowIndex} className={styles.colorRow}>
2126
+ {row.map((color) => (
2127
+ <button
2128
+ key={color}
2129
+ type="button"
2130
+ className={styles.colorButton}
2131
+ style={{ backgroundColor: color }}
2132
+ onMouseDown={(e) => e.preventDefault()} // 포커스 이동 방지
2133
+ onClick={() => {
2134
+ changeBackgroundColor(color, savedSelection);
2135
+ setIsBgColorOpen(false);
2136
+ setSavedSelection(null);
2137
+ }}
2138
+ />
2139
+ ))}
2140
+ </div>
2141
+ ))}
2142
+ </div>
2143
+ )}
2144
+ </div>
2145
+ </div>
2146
+
2147
+ <div className={styles.toolbarGroup}>
2148
+ <div ref={alignButtonRef} style={{ position: 'relative' }}>
2149
+ <button
2150
+ type="button"
2151
+ className={styles.toolbarButton}
2152
+ onClick={() => {
2153
+ setIsAlignDropdownOpen(!isAlignDropdownOpen);
2154
+ setIsParagraphDropdownOpen(false);
2155
+ setIsTextColorOpen(false);
2156
+ setIsBgColorOpen(false);
2157
+ }}
2158
+ title={getCurrentAlignLabel()}
2159
+ >
2160
+ <i className={getCurrentAlignIcon()} />
2161
+ </button>
2162
+
2163
+ {isAlignDropdownOpen && (
2164
+ <div
2165
+ className={styles.alignDropdown}
2166
+ style={{
2167
+ top: alignButtonRef.current?.getBoundingClientRect().bottom ?? 0,
2168
+ left: alignButtonRef.current?.getBoundingClientRect().left ?? 0
2169
+ }}
2170
+ >
2171
+ {alignOptions.map((option) => (
2172
+ <button
2173
+ key={option.value}
2174
+ type="button"
2175
+ className={`${styles.alignOption} ${currentAlign === option.value ? styles.active : ''}`}
2176
+ onClick={() => {
2177
+ if (option.value === 'left') {
2178
+ execCommand('justifyLeft');
2179
+ } else if (option.value === 'center') {
2180
+ execCommand('justifyCenter');
2181
+ } else if (option.value === 'right') {
2182
+ execCommand('justifyRight');
2183
+ }
2184
+ setCurrentAlign(option.value);
2185
+ setIsAlignDropdownOpen(false);
2186
+ }}
2187
+ title={option.label}
2188
+ >
2189
+ <i className={styles[option.icon]} />
2190
+ </button>
2191
+ ))}
2192
+ </div>
2193
+ )}
2194
+ </div>
2195
+
2196
+ <button
2197
+ type="button"
2198
+ className={styles.toolbarButton}
2199
+ onClick={() => execCommand('insertUnorderedList')}
2200
+ title="목록"
2201
+ >
2202
+ <i className={styles.listUl} />
2203
+ </button>
2204
+ <button
2205
+ type="button"
2206
+ className={styles.toolbarButton}
2207
+ onClick={() => execCommand('insertOrderedList')}
2208
+ title="번호 목록"
2209
+ >
2210
+ <i className={styles.listOl} />
2211
+ </button>
2212
+ </div>
2213
+
2214
+ <div className={styles.toolbarGroup}>
2215
+ <div ref={linkButtonRef} style={{ position: 'relative' }}>
2216
+ <button
2217
+ type="button"
2218
+ className={styles.toolbarButton}
2219
+ onClick={openLinkDropdown}
2220
+ title="링크"
2221
+ >
2222
+ <i className={styles.link} />
2223
+ </button>
2224
+
2225
+ {isLinkDropdownOpen && (
2226
+ <div
2227
+ className={styles.linkDropdown}
2228
+ style={{
2229
+ top: linkButtonRef.current?.getBoundingClientRect().bottom ?? 0,
2230
+ left: linkButtonRef.current?.getBoundingClientRect().left ?? 0
2231
+ }}
2232
+ >
2233
+ <div className={styles.linkInput}>
2234
+ <label>URL</label>
2235
+ <input
2236
+ type="text"
2237
+ value={linkUrl}
2238
+ onChange={(e) => setLinkUrl(e.target.value)}
2239
+ placeholder="https://..."
2240
+ autoFocus
2241
+ />
2242
+ </div>
2243
+ <div className={styles.linkTarget}>
2244
+ <label>
2245
+ <input
2246
+ type="radio"
2247
+ value="_blank"
2248
+ checked={linkTarget === '_blank'}
2249
+ onChange={(e) => setLinkTarget(e.target.value)}
2250
+ />
2251
+ 새 창에서 열기
2252
+ </label>
2253
+ <label>
2254
+ <input
2255
+ type="radio"
2256
+ value="_self"
2257
+ checked={linkTarget === '_self'}
2258
+ onChange={(e) => setLinkTarget(e.target.value)}
2259
+ />
2260
+ 현재 창에서 열기
2261
+ </label>
2262
+ </div>
2263
+ <div className={styles.linkActions}>
2264
+ <button
2265
+ type="button"
2266
+ onClick={() => {
2267
+ setIsLinkDropdownOpen(false);
2268
+ setLinkUrl('');
2269
+ setLinkTarget('_blank');
2270
+ setSavedSelection(null);
2271
+ }}
2272
+ className={styles.default}
2273
+ >
2274
+ 취소
2275
+ </button>
2276
+ <button
2277
+ type="button"
2278
+ onClick={applyLink}
2279
+ disabled={!linkUrl}
2280
+ className={styles.primary}
2281
+ >
2282
+ 삽입
2283
+ </button>
2284
+ </div>
2285
+ </div>
2286
+ )}
2287
+ </div>
2288
+
2289
+ <div ref={imageButtonRef} style={{ position: 'relative' }}>
2290
+ <button
2291
+ type="button"
2292
+ className={styles.toolbarButton}
2293
+ onClick={openImageDropdown}
2294
+ title="이미지"
2295
+ >
2296
+ <i className={styles.image} />
2297
+ </button>
2298
+
2299
+ {isImageDropdownOpen && (
2300
+ <div
2301
+ className={styles.imageDropdown}
2302
+ style={{
2303
+ top: imageButtonRef.current?.getBoundingClientRect().bottom ?? 0,
2304
+ left: imageButtonRef.current?.getBoundingClientRect().left ?? 0
2305
+ }}
2306
+ >
2307
+ <div className={styles.imageTabSection}>
2308
+ <div className={styles.imageTabButtons}>
2309
+ <button
2310
+ type="button"
2311
+ className={imageTabMode === 'file' ? styles.active : ''}
2312
+ onClick={() => {
2313
+ setImageTabMode('file');
2314
+ setImageUrl(''); // URL 초기화
2315
+ }}
2316
+ >
2317
+ 파일 업로드
2318
+ </button>
2319
+ <button
2320
+ type="button"
2321
+ className={imageTabMode === 'url' ? styles.active : ''}
2322
+ onClick={() => {
2323
+ setImageTabMode('url');
2324
+ setImageFile(null); // 파일 초기화
2325
+ setImagePreview(''); // 프리뷰 초기화
2326
+ }}
2327
+ >
2328
+ URL 입력
2329
+ </button>
2330
+ </div>
2331
+
2332
+ {/* 파일 업로드 탭 */}
2333
+ {imageTabMode === 'file' && (
2334
+ <div className={styles.imageFileSection}>
2335
+ <input
2336
+ ref={imageFileInputRef}
2337
+ type="file"
2338
+ accept="image/*"
2339
+ onChange={handleImageFileSelect}
2340
+ style={{ display: 'none' }}
2341
+ />
2342
+ <button
2343
+ type="button"
2344
+ onClick={() => imageFileInputRef.current?.click()}
2345
+ className={styles.fileSelectButton}
2346
+ >
2347
+ {imageFile ? imageFile.name : '파일 선택'}
2348
+ </button>
2349
+ {imagePreview && (
2350
+ <div className={styles.imagePreviewBox}>
2351
+ <img src={imagePreview} alt="Preview" />
2352
+ </div>
2353
+ )}
2354
+ </div>
2355
+ )}
2356
+
2357
+ {/* URL 입력 탭 */}
2358
+ {imageTabMode === 'url' && (
2359
+ <div className={styles.imageUrlSection}>
2360
+ <input
2361
+ type="text"
2362
+ value={imageUrl}
2363
+ onChange={(e) => {
2364
+ setImageUrl(e.target.value);
2365
+ }}
2366
+ placeholder="https://..."
2367
+ />
2368
+ </div>
2369
+ )}
2370
+ </div>
2371
+
2372
+ <div className={styles.imageOptions}>
2373
+ <div className={styles.imageOptionRow}>
2374
+ <label>크기</label>
2375
+ <div className={styles.imageSizeButtons}>
2376
+ <button
2377
+ type="button"
2378
+ className={imageWidth === '100%' ? styles.active : ''}
2379
+ onClick={() => setImageWidth('100%')}
2380
+ >
2381
+ 100%
2382
+ </button>
2383
+ <button
2384
+ type="button"
2385
+ className={imageWidth === '75%' ? styles.active : ''}
2386
+ onClick={() => setImageWidth('75%')}
2387
+ >
2388
+ 75%
2389
+ </button>
2390
+ <button
2391
+ type="button"
2392
+ className={imageWidth === '50%' ? styles.active : ''}
2393
+ onClick={() => setImageWidth('50%')}
2394
+ >
2395
+ 50%
2396
+ </button>
2397
+ <button
2398
+ type="button"
2399
+ className={imageWidth === 'original' ? styles.active : ''}
2400
+ onClick={() => setImageWidth('original')}
2401
+ >
2402
+ 원본
2403
+ </button>
2404
+ </div>
2405
+ </div>
2406
+
2407
+ <div className={styles.imageOptionRow}>
2408
+ <label>정렬</label>
2409
+ <div className={styles.imageAlignButtons}>
2410
+ <button
2411
+ type="button"
2412
+ className={imageAlign === 'left' ? styles.active : ''}
2413
+ onClick={() => setImageAlign('left')}
2414
+ title="왼쪽 정렬"
2415
+ >
2416
+ <i className={styles.alignLeft} />
2417
+ </button>
2418
+ <button
2419
+ type="button"
2420
+ className={imageAlign === 'center' ? styles.active : ''}
2421
+ onClick={() => setImageAlign('center')}
2422
+ title="가운데 정렬"
2423
+ >
2424
+ <i className={styles.alignCenter} />
2425
+ </button>
2426
+ <button
2427
+ type="button"
2428
+ className={imageAlign === 'right' ? styles.active : ''}
2429
+ onClick={() => setImageAlign('right')}
2430
+ title="오른쪽 정렬"
2431
+ >
2432
+ <i className={styles.alignRight} />
2433
+ </button>
2434
+ </div>
2435
+ </div>
2436
+
2437
+ <div className={styles.imageOptionRow}>
2438
+ <label>대체 텍스트</label>
2439
+ <input
2440
+ type="text"
2441
+ value={imageAlt}
2442
+ onChange={(e) => setImageAlt(e.target.value)}
2443
+ placeholder="이미지 설명..."
2444
+ />
2445
+ </div>
2446
+ </div>
2447
+
2448
+ <div className={styles.imageActions}>
2449
+ <button
2450
+ type="button"
2451
+ onClick={() => {
2452
+ setIsImageDropdownOpen(false);
2453
+ setImageTabMode('file'); // 탭 모드 초기화
2454
+ setImageUrl('');
2455
+ setImageFile(null);
2456
+ setImagePreview('');
2457
+ setImageWidth('original'); // 원본으로 초기화
2458
+ setImageAlign('left'); // 좌측으로 초기화
2459
+ setImageAlt('');
2460
+ setSavedImageSelection(null); // 저장된 선택 영역 초기화
2461
+ }}
2462
+ className={styles.default}
2463
+ >
2464
+ 취소
2465
+ </button>
2466
+ <button
2467
+ type="button"
2468
+ onClick={insertImage}
2469
+ disabled={!imageUrl && !imageFile}
2470
+ className={styles.primary}
2471
+ >
2472
+ 삽입
2473
+ </button>
2474
+ </div>
2475
+ </div>
2476
+ )}
2477
+ </div>
2478
+
2479
+ <div ref={youtubeButtonRef} style={{ position: 'relative' }}>
2480
+ <button
2481
+ type="button"
2482
+ className={styles.toolbarButton}
2483
+ onClick={(e) => {
2484
+ e.stopPropagation();
2485
+ // 현재 선택 영역 저장
2486
+ const selection = window.getSelection();
2487
+ if (selection && selection.rangeCount > 0) {
2488
+ const range = selection.getRangeAt(0).cloneRange();
2489
+ setSavedYoutubeSelection(range);
2490
+ } else {
2491
+ setSavedYoutubeSelection(null);
2492
+ }
2493
+
2494
+ setIsYoutubeDropdownOpen(true);
2495
+ setIsImageDropdownOpen(false);
2496
+ setIsParagraphDropdownOpen(false);
2497
+ setIsTextColorOpen(false);
2498
+ setIsBgColorOpen(false);
2499
+ setIsAlignDropdownOpen(false);
2500
+ setIsLinkDropdownOpen(false);
2501
+ }}
2502
+ title="유튜브"
2503
+ >
2504
+ <i className={styles.youtube} />
2505
+ </button>
2506
+
2507
+ {isYoutubeDropdownOpen && (
2508
+ <div
2509
+ className={styles.imageDropdown}
2510
+ style={{
2511
+ top: youtubeButtonRef.current?.getBoundingClientRect().bottom ?? 0,
2512
+ left: youtubeButtonRef.current?.getBoundingClientRect().left ?? 0
2513
+ }}
2514
+ >
2515
+ <div className={styles.imageTabSection}>
2516
+ <div className={styles.imageTabButtons}>
2517
+ <button
2518
+ type="button"
2519
+ className={styles.active}
2520
+ style={{ width: '100%' }}
2521
+ >
2522
+ 유튜브 URL
2523
+ </button>
2524
+ </div>
2525
+
2526
+ <div className={styles.imageUrlSection}>
2527
+ <input
2528
+ type="text"
2529
+ value={youtubeUrl}
2530
+ onChange={(e) => setYoutubeUrl(e.target.value)}
2531
+ placeholder="https://www.youtube.com/watch?v=... 또는 https://youtu.be/..."
2532
+ autoFocus
2533
+ />
2534
+ </div>
2535
+ </div>
2536
+
2537
+ <div className={styles.imageOptions}>
2538
+ <div className={styles.imageOptionRow}>
2539
+ <label>크기</label>
2540
+ <div className={styles.imageSizeButtons}>
2541
+ <button
2542
+ type="button"
2543
+ className={youtubeWidth === '100%' ? styles.active : ''}
2544
+ onClick={() => setYoutubeWidth('100%')}
2545
+ >
2546
+ 100%
2547
+ </button>
2548
+ <button
2549
+ type="button"
2550
+ className={youtubeWidth === '75%' ? styles.active : ''}
2551
+ onClick={() => setYoutubeWidth('75%')}
2552
+ >
2553
+ 75%
2554
+ </button>
2555
+ <button
2556
+ type="button"
2557
+ className={youtubeWidth === '50%' ? styles.active : ''}
2558
+ onClick={() => setYoutubeWidth('50%')}
2559
+ >
2560
+ 50%
2561
+ </button>
2562
+ <button
2563
+ type="button"
2564
+ className={youtubeWidth === 'original' ? styles.active : ''}
2565
+ onClick={() => setYoutubeWidth('original')}
2566
+ >
2567
+ 원본
2568
+ </button>
2569
+ </div>
2570
+ </div>
2571
+
2572
+ <div className={styles.imageOptionRow}>
2573
+ <label>정렬</label>
2574
+ <div className={styles.imageAlignButtons}>
2575
+ <button
2576
+ type="button"
2577
+ className={youtubeAlign === 'left' ? styles.active : ''}
2578
+ onClick={() => setYoutubeAlign('left')}
2579
+ title="왼쪽 정렬"
2580
+ >
2581
+ <i className={styles.alignLeft} />
2582
+ </button>
2583
+ <button
2584
+ type="button"
2585
+ className={youtubeAlign === 'center' ? styles.active : ''}
2586
+ onClick={() => setYoutubeAlign('center')}
2587
+ title="가운데 정렬"
2588
+ >
2589
+ <i className={styles.alignCenter} />
2590
+ </button>
2591
+ <button
2592
+ type="button"
2593
+ className={youtubeAlign === 'right' ? styles.active : ''}
2594
+ onClick={() => setYoutubeAlign('right')}
2595
+ title="오른쪽 정렬"
2596
+ >
2597
+ <i className={styles.alignRight} />
2598
+ </button>
2599
+ </div>
2600
+ </div>
2601
+ </div>
2602
+
2603
+ <div className={styles.imageActions}>
2604
+ <button
2605
+ type="button"
2606
+ className={styles.default}
2607
+ onClick={() => {
2608
+ setIsYoutubeDropdownOpen(false);
2609
+ setYoutubeUrl('');
2610
+ setYoutubeWidth('100%');
2611
+ setYoutubeAlign('center');
2612
+ setSavedYoutubeSelection(null);
2613
+ }}
2614
+ >
2615
+ 취소
2616
+ </button>
2617
+ <button
2618
+ type="button"
2619
+ className={styles.primary}
2620
+ onClick={() => insertYoutube()}
2621
+ disabled={!youtubeUrl}
2622
+ >
2623
+ 삽입
2624
+ </button>
2625
+ </div>
2626
+ </div>
2627
+ )}
2628
+ </div>
2629
+ </div>
2630
+
2631
+ <div className={styles.toolbarGroup}>
2632
+ <button
2633
+ type="button"
2634
+ className={styles.toolbarButton}
2635
+ onClick={() => execCommand('removeFormat')}
2636
+ title="서식 지우기"
2637
+ >
2638
+ <i className={styles.eraser} />
2639
+ </button>
2640
+ </div>
2641
+
2642
+ <div className={styles.toolbarGroup}>
2643
+ <button
2644
+ type="button"
2645
+ className={`${styles.toolbarButton} ${isCodeView ? styles.active : ''}`}
2646
+ onClick={toggleCodeView}
2647
+ title={isCodeView ? "에디터로 전환" : "HTML 코드보기"}
2648
+ >
2649
+ <i className={styles.code} />
2650
+ </button>
2651
+ </div>
2652
+ </div>
2653
+
2654
+ <div
2655
+ ref={containerRef}
2656
+ className={`${styles.editorContainer} ${resizable ? styles.resizable : ''}`}
2657
+ style={{
2658
+ height: height === 'contents' ? 'auto' : (height || '300px'),
2659
+ minHeight: minHeight || (height === 'contents' ? '100px' : '200px'),
2660
+ maxHeight: maxHeight || (height === 'contents' ? undefined : undefined),
2661
+ display: 'flex',
2662
+ flexDirection: 'column',
2663
+ resize: resizable ? 'vertical' : 'none',
2664
+ overflow: 'auto'
2665
+ }}
2666
+ >
2667
+ {isCodeView ? (
2668
+ <textarea
2669
+ ref={codeEditorRef}
2670
+ className={styles.codeEditor}
2671
+ value={codeContent}
2672
+ onChange={handleCodeChange}
2673
+ spellCheck={false}
2674
+ style={{
2675
+ flex: height === 'contents' ? '0 0 auto' : 1,
2676
+ minHeight: height === 'contents' ? 'auto' : 0,
2677
+ height: height === 'contents' && savedEditorHeight ? `${savedEditorHeight}px` : undefined,
2678
+ resize: 'none'
2679
+ }}
2680
+ placeholder={placeholder}
2681
+ />
2682
+ ) : (
2683
+ <div
2684
+ ref={editorRef}
2685
+ id={editorID}
2686
+ className={styles.editorContent}
2687
+ contentEditable
2688
+ onInput={handleInput}
2689
+ onClick={handleEditorClick}
2690
+ onKeyUp={() => {
2691
+ detectCurrentParagraphStyle();
2692
+ detectCurrentAlign();
2693
+ }}
2694
+ onKeyDown={handleKeyDown}
2695
+ style={{
2696
+ flex: height === 'contents' ? '0 0 auto' : 1,
2697
+ minHeight: height === 'contents' ? 'auto' : 0,
2698
+ overflowY: height === 'contents' ? 'visible' : 'auto'
2699
+ }}
2700
+ data-placeholder={placeholder}
2701
+ />
2702
+ )}
2703
+ </div>
2704
+
2705
+ {validator && message && (
2706
+ <div className={styles.validator}>{message}</div>
2707
+ )}
2708
+
2709
+ {/* 숨겨진 파일 입력 필드 */}
2710
+ <input
2711
+ ref={fileInputRef}
2712
+ type="file"
2713
+ accept="image/*"
2714
+ onChange={handleFileUpload}
2715
+ style={{ display: 'none' }}
2716
+ />
2717
+
2718
+ {/* 링크 수정 팝업 */}
2719
+ {isEditLinkPopupOpen && selectedLinkElement && (
2720
+ <div
2721
+ className={styles.editLinkPopup}
2722
+ style={{
2723
+ position: 'absolute',
2724
+ top: selectedLinkElement.offsetTop + selectedLinkElement.offsetHeight + 5,
2725
+ left: selectedLinkElement.offsetLeft
2726
+ }}
2727
+ >
2728
+ <div className={styles.editLinkContent}>
2729
+ <div className={styles.editLinkInput}>
2730
+ <label>URL 수정</label>
2731
+ <input
2732
+ type="text"
2733
+ value={editLinkUrl}
2734
+ onChange={(e) => setEditLinkUrl(e.target.value)}
2735
+ placeholder="https://..."
2736
+ autoFocus
2737
+ />
2738
+ </div>
2739
+ <div className={styles.editLinkTarget}>
2740
+ <label>
2741
+ <input
2742
+ type="radio"
2743
+ value="_blank"
2744
+ checked={editLinkTarget === '_blank'}
2745
+ onChange={(e) => setEditLinkTarget(e.target.value)}
2746
+ />
2747
+ 새 창에서 열기
2748
+ </label>
2749
+ <label>
2750
+ <input
2751
+ type="radio"
2752
+ value="_self"
2753
+ checked={editLinkTarget === '_self'}
2754
+ onChange={(e) => setEditLinkTarget(e.target.value)}
2755
+ />
2756
+ 현재 창에서 열기
2757
+ </label>
2758
+ </div>
2759
+ <div className={styles.editLinkActions}>
2760
+ <button
2761
+ type="button"
2762
+ onClick={removeLink}
2763
+ className={styles.danger}
2764
+ >
2765
+ 링크 삭제
2766
+ </button>
2767
+ <div style={{ display: 'flex', gap: '8px' }}>
2768
+ <button
2769
+ type="button"
2770
+ onClick={() => {
2771
+ setIsEditLinkPopupOpen(false);
2772
+ setSelectedLinkElement(null);
2773
+ setEditLinkUrl('');
2774
+ setEditLinkTarget('_self');
2775
+ }}
2776
+ className={styles.default}
2777
+ >
2778
+ 취소
2779
+ </button>
2780
+ <button
2781
+ type="button"
2782
+ onClick={updateLink}
2783
+ disabled={!editLinkUrl}
2784
+ className={styles.primary}
2785
+ >
2786
+ 적용
2787
+ </button>
2788
+ </div>
2789
+ </div>
2790
+ </div>
2791
+ </div>
2792
+ )}
2793
+
2794
+ {/* 이미지 편집 팝업 */}
2795
+ {isImageEditPopupOpen && selectedImage && (() => {
2796
+ // 이미지의 wrapper를 찾기 (wrapper가 있으면 wrapper 기준, 없으면 이미지 기준)
2797
+ const imageWrapper = selectedImage.parentElement?.classList.contains('image-wrapper')
2798
+ ? selectedImage.parentElement
2799
+ : selectedImage;
2800
+
2801
+ return (
2802
+ <div
2803
+ className={styles.imageDropdown}
2804
+ style={{
2805
+ position: 'fixed',
2806
+ top: imageWrapper.getBoundingClientRect().bottom + 10,
2807
+ left: Math.max(10, Math.min(
2808
+ imageWrapper.getBoundingClientRect().left + (imageWrapper.getBoundingClientRect().width / 2) - 180,
2809
+ window.innerWidth - 370
2810
+ )),
2811
+ zIndex: 9999,
2812
+ minWidth: '360px',
2813
+ maxWidth: '90%'
2814
+ }}
2815
+ >
2816
+ <h3 style={{ margin: '0 0 15px 0', fontSize: '16px', fontWeight: '600' }}>이미지 편집</h3>
2817
+
2818
+ <div className={styles.imageOptions} style={{ marginBottom: '0' }}>
2819
+ <div className={styles.imageOptionRow}>
2820
+ <label>크기</label>
2821
+ <div className={styles.imageSizeButtons}>
2822
+ <button
2823
+ type="button"
2824
+ onClick={() => setEditImageWidth('100%')}
2825
+ className={editImageWidth === '100%' ? styles.active : ''}
2826
+ >
2827
+ 100%
2828
+ </button>
2829
+ <button
2830
+ type="button"
2831
+ onClick={() => setEditImageWidth('75%')}
2832
+ className={editImageWidth === '75%' ? styles.active : ''}
2833
+ >
2834
+ 75%
2835
+ </button>
2836
+ <button
2837
+ type="button"
2838
+ onClick={() => setEditImageWidth('50%')}
2839
+ className={editImageWidth === '50%' ? styles.active : ''}
2840
+ >
2841
+ 50%
2842
+ </button>
2843
+ <button
2844
+ type="button"
2845
+ onClick={() => setEditImageWidth('original')}
2846
+ className={editImageWidth === 'original' ? styles.active : ''}
2847
+ >
2848
+ 원본
2849
+ </button>
2850
+ </div>
2851
+ </div>
2852
+
2853
+ <div className={styles.imageOptionRow}>
2854
+ <label>정렬</label>
2855
+ <div className={styles.imageAlignButtons}>
2856
+ <button
2857
+ type="button"
2858
+ onClick={() => setEditImageAlign('left')}
2859
+ title="왼쪽 정렬"
2860
+ className={editImageAlign === 'left' ? styles.active : ''}
2861
+ >
2862
+ <i className={styles.alignLeft} />
2863
+ </button>
2864
+ <button
2865
+ type="button"
2866
+ onClick={() => setEditImageAlign('center')}
2867
+ title="가운데 정렬"
2868
+ className={editImageAlign === 'center' ? styles.active : ''}
2869
+ >
2870
+ <i className={styles.alignCenter} />
2871
+ </button>
2872
+ <button
2873
+ type="button"
2874
+ onClick={() => setEditImageAlign('right')}
2875
+ title="오른쪽 정렬"
2876
+ className={editImageAlign === 'right' ? styles.active : ''}
2877
+ >
2878
+ <i className={styles.alignRight} />
2879
+ </button>
2880
+ </div>
2881
+ </div>
2882
+
2883
+ <div className={styles.imageOptionRow}>
2884
+ <label>대체 텍스트</label>
2885
+ <input
2886
+ type="text"
2887
+ value={editImageAlt}
2888
+ onChange={(e) => setEditImageAlt(e.target.value)}
2889
+ placeholder="이미지 설명..."
2890
+ />
2891
+ </div>
2892
+ </div>
2893
+
2894
+ <div className={styles.imageActions}>
2895
+ <button
2896
+ type="button"
2897
+ onClick={deleteImage}
2898
+ className={styles.danger}
2899
+ >
2900
+ 삭제
2901
+ </button>
2902
+ <div style={{ display: 'flex', gap: '8px' }}>
2903
+ <button
2904
+ type="button"
2905
+ onClick={deselectImage}
2906
+ className={styles.default}
2907
+ >
2908
+ 취소
2909
+ </button>
2910
+ <button
2911
+ type="button"
2912
+ onClick={applyImageEdit}
2913
+ className={styles.primary}
2914
+ >
2915
+ 적용
2916
+ </button>
2917
+ </div>
2918
+ </div>
2919
+ </div>
2920
+ )})()}
2921
+
2922
+ {/* 유튜브 편집 팝업 */}
2923
+ {isYoutubeEditPopupOpen && selectedYoutube && (() => {
2924
+ // 유튜브의 wrapper를 찾기
2925
+ const youtubeWrapper = selectedYoutube.parentElement?.classList.contains('youtube-wrapper')
2926
+ ? selectedYoutube.parentElement
2927
+ : selectedYoutube;
2928
+
2929
+ return (
2930
+ <div
2931
+ className={styles.imageDropdown}
2932
+ style={{
2933
+ position: 'fixed',
2934
+ top: youtubeWrapper.getBoundingClientRect().bottom + 10,
2935
+ left: Math.max(10, Math.min(
2936
+ youtubeWrapper.getBoundingClientRect().left + (youtubeWrapper.getBoundingClientRect().width / 2) - 180,
2937
+ window.innerWidth - 370
2938
+ )),
2939
+ zIndex: 9999,
2940
+ minWidth: '360px',
2941
+ maxWidth: '90%'
2942
+ }}
2943
+ >
2944
+ <h3 style={{ margin: '0 0 15px 0', fontSize: '16px', fontWeight: '600' }}>유튜브 편집</h3>
2945
+
2946
+ <div className={styles.imageOptions} style={{ marginBottom: '0' }}>
2947
+ <div className={styles.imageOptionRow}>
2948
+ <label>크기</label>
2949
+ <div className={styles.imageSizeButtons}>
2950
+ <button
2951
+ type="button"
2952
+ onClick={() => setEditYoutubeWidth('100%')}
2953
+ className={editYoutubeWidth === '100%' ? styles.active : ''}
2954
+ >
2955
+ 100%
2956
+ </button>
2957
+ <button
2958
+ type="button"
2959
+ onClick={() => setEditYoutubeWidth('75%')}
2960
+ className={editYoutubeWidth === '75%' ? styles.active : ''}
2961
+ >
2962
+ 75%
2963
+ </button>
2964
+ <button
2965
+ type="button"
2966
+ onClick={() => setEditYoutubeWidth('50%')}
2967
+ className={editYoutubeWidth === '50%' ? styles.active : ''}
2968
+ >
2969
+ 50%
2970
+ </button>
2971
+ <button
2972
+ type="button"
2973
+ onClick={() => setEditYoutubeWidth('original')}
2974
+ className={editYoutubeWidth === 'original' ? styles.active : ''}
2975
+ >
2976
+ 원본
2977
+ </button>
2978
+ </div>
2979
+ </div>
2980
+
2981
+ <div className={styles.imageOptionRow}>
2982
+ <label>정렬</label>
2983
+ <div className={styles.imageAlignButtons}>
2984
+ <button
2985
+ type="button"
2986
+ onClick={() => setEditYoutubeAlign('left')}
2987
+ title="왼쪽 정렬"
2988
+ className={editYoutubeAlign === 'left' ? styles.active : ''}
2989
+ >
2990
+ <i className={styles.alignLeft} />
2991
+ </button>
2992
+ <button
2993
+ type="button"
2994
+ onClick={() => setEditYoutubeAlign('center')}
2995
+ title="가운데 정렬"
2996
+ className={editYoutubeAlign === 'center' ? styles.active : ''}
2997
+ >
2998
+ <i className={styles.alignCenter} />
2999
+ </button>
3000
+ <button
3001
+ type="button"
3002
+ onClick={() => setEditYoutubeAlign('right')}
3003
+ title="오른쪽 정렬"
3004
+ className={editYoutubeAlign === 'right' ? styles.active : ''}
3005
+ >
3006
+ <i className={styles.alignRight} />
3007
+ </button>
3008
+ </div>
3009
+ </div>
3010
+ </div>
3011
+
3012
+ <div className={styles.imageActions}>
3013
+ <button
3014
+ type="button"
3015
+ className={styles.danger}
3016
+ onClick={deleteYoutube}
3017
+ >
3018
+ 삭제
3019
+ </button>
3020
+ <div style={{ display: 'flex', gap: '8px' }}>
3021
+ <button
3022
+ type="button"
3023
+ className={styles.default}
3024
+ onClick={deselectYoutube}
3025
+ >
3026
+ 취소
3027
+ </button>
3028
+ <button
3029
+ type="button"
3030
+ className={styles.primary}
3031
+ onClick={applyYoutubeEdit}
3032
+ >
3033
+ 적용
3034
+ </button>
3035
+ </div>
3036
+ </div>
3037
+ </div>
3038
+ )
3039
+ })()}
167
3040
  </div>
168
3041
  );
169
3042
  };
170
3043
 
171
- export default SunEditor;
3044
+ export default Editor;