podo-ui 0.3.6 → 0.3.8

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