podo-ui 0.2.1 → 0.3.1

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