podo-ui 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/next.ts +4 -1
- package/package.json +1 -1
- package/react/atom/editor-view.module.scss +203 -0
- package/react/atom/editor-view.tsx +17 -0
- package/react/atom/editor.tsx +276 -1
- package/react.ts +4 -2
- /package/react/{atom → molecule}/pagination.module.scss +0 -0
- /package/react/{atom → molecule}/pagination.tsx +0 -0
package/next.ts
CHANGED
|
@@ -3,6 +3,8 @@ import dynamic from 'next/dynamic';
|
|
|
3
3
|
|
|
4
4
|
import Input from './react/atom/input';
|
|
5
5
|
import Textarea from './react/atom/textarea';
|
|
6
|
+
import EditorView from './react/atom/editor-view';
|
|
7
|
+
import Pagination from './react/molecule/pagination';
|
|
6
8
|
import Field from './react/molecule/field';
|
|
7
9
|
const Editor = dynamic(() => import('./react/atom/editor'), { ssr: false });
|
|
8
10
|
|
|
@@ -10,9 +12,10 @@ const Form = {
|
|
|
10
12
|
Input,
|
|
11
13
|
Textarea,
|
|
12
14
|
Editor,
|
|
15
|
+
EditorView,
|
|
13
16
|
Field,
|
|
14
17
|
};
|
|
15
18
|
|
|
16
19
|
export default Form;
|
|
17
20
|
|
|
18
|
-
export { Input, Textarea, Editor, Field };
|
|
21
|
+
export { Input, Textarea, Editor, EditorView, Pagination, Field };
|
package/package.json
CHANGED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
@use '../../scss/color/function.scss' as *;
|
|
2
|
+
@use '../../scss/typo/mixin.scss' as typo;
|
|
3
|
+
|
|
4
|
+
.editorView {
|
|
5
|
+
color: color(text-body);
|
|
6
|
+
font-family: inherit;
|
|
7
|
+
word-wrap: break-word;
|
|
8
|
+
overflow-wrap: break-word;
|
|
9
|
+
|
|
10
|
+
// 텍스트 서식
|
|
11
|
+
strong,
|
|
12
|
+
b {
|
|
13
|
+
font-weight: bold;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
em,
|
|
17
|
+
i {
|
|
18
|
+
font-style: italic;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
u {
|
|
22
|
+
text-decoration: underline;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
s,
|
|
26
|
+
strike {
|
|
27
|
+
text-decoration: line-through;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 제목 - podo-ui 스타일 상속
|
|
31
|
+
h1 {
|
|
32
|
+
@include typo.h1;
|
|
33
|
+
margin: 0.67em 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
h2 {
|
|
37
|
+
@include typo.h2;
|
|
38
|
+
margin: 0.75em 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
h3 {
|
|
42
|
+
@include typo.h3;
|
|
43
|
+
margin: 0.83em 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// p1~p5 스타일 클래스
|
|
47
|
+
.p1 {
|
|
48
|
+
@include typo.p1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.p2 {
|
|
52
|
+
@include typo.p2;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.p3 {
|
|
56
|
+
@include typo.p3;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.p4 {
|
|
60
|
+
@include typo.p4;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.p5 {
|
|
64
|
+
@include typo.p5;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Semibold 스타일
|
|
68
|
+
.p1_semibold {
|
|
69
|
+
@include typo.p1;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.p2_semibold {
|
|
74
|
+
@include typo.p2;
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.p3_semibold {
|
|
79
|
+
@include typo.p3-semibold;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.p4_semibold {
|
|
83
|
+
@include typo.p4-semibold;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.p5_semibold {
|
|
87
|
+
@include typo.p5-semibold;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 단락 - 기본 본문 스타일 p3 적용
|
|
91
|
+
p {
|
|
92
|
+
@include typo.p3;
|
|
93
|
+
margin: 1em 0;
|
|
94
|
+
|
|
95
|
+
&:first-child {
|
|
96
|
+
margin-top: 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
&:last-child {
|
|
100
|
+
margin-bottom: 0;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 링크
|
|
105
|
+
a {
|
|
106
|
+
color: color(link);
|
|
107
|
+
text-decoration: underline;
|
|
108
|
+
cursor: pointer;
|
|
109
|
+
|
|
110
|
+
&:hover {
|
|
111
|
+
color: color(link-hover);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 목록 - podo-ui p3 스타일 적용
|
|
116
|
+
ul,
|
|
117
|
+
ol {
|
|
118
|
+
@include typo.p3;
|
|
119
|
+
margin: 1em 0 !important;
|
|
120
|
+
padding-left: 2em !important;
|
|
121
|
+
padding-top: 0 !important;
|
|
122
|
+
padding-right: 0 !important;
|
|
123
|
+
padding-bottom: 0 !important;
|
|
124
|
+
|
|
125
|
+
&:first-child {
|
|
126
|
+
margin-top: 0 !important;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
&:last-child {
|
|
130
|
+
margin-bottom: 0 !important;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
ul {
|
|
135
|
+
list-style: disc !important;
|
|
136
|
+
list-style-type: disc !important;
|
|
137
|
+
list-style-position: outside !important;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
ol {
|
|
141
|
+
list-style: decimal !important;
|
|
142
|
+
list-style-type: decimal !important;
|
|
143
|
+
list-style-position: outside !important;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
li {
|
|
147
|
+
display: list-item !important;
|
|
148
|
+
margin: 0.5em 0 !important;
|
|
149
|
+
padding: 0 !important;
|
|
150
|
+
list-style: inherit !important;
|
|
151
|
+
list-style-position: outside !important;
|
|
152
|
+
|
|
153
|
+
// 중첩된 리스트
|
|
154
|
+
ul,
|
|
155
|
+
ol {
|
|
156
|
+
margin: 0.5em 0 !important;
|
|
157
|
+
padding-left: 1.5em !important;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
ul {
|
|
161
|
+
list-style-type: circle !important;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
ol {
|
|
165
|
+
list-style-type: lower-alpha !important;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 이미지
|
|
170
|
+
img {
|
|
171
|
+
display: inline-block !important;
|
|
172
|
+
vertical-align: middle;
|
|
173
|
+
max-width: 100%;
|
|
174
|
+
height: auto;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 인용
|
|
178
|
+
blockquote {
|
|
179
|
+
@include typo.p3;
|
|
180
|
+
margin: 1em 0;
|
|
181
|
+
padding-left: 1em;
|
|
182
|
+
border-left: 4px solid color(border);
|
|
183
|
+
color: color(text-sub);
|
|
184
|
+
font-style: italic;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 유튜브 컨테이너 (editor에서 삽입된 스타일 유지)
|
|
188
|
+
:global(.youtube-container) {
|
|
189
|
+
position: relative;
|
|
190
|
+
display: inline-block;
|
|
191
|
+
max-width: 100%;
|
|
192
|
+
|
|
193
|
+
iframe {
|
|
194
|
+
width: 100%;
|
|
195
|
+
height: 100%;
|
|
196
|
+
display: block;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
:global(.youtube-overlay) {
|
|
200
|
+
display: none; // view 모드에서는 오버레이 숨김
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import styles from './editor-view.module.scss';
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
value: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const EditorView = ({ value, className }: Props) => {
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
className={`${styles.editorView} ${className || ''}`}
|
|
12
|
+
dangerouslySetInnerHTML={{ __html: value }}
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default EditorView;
|
package/react/atom/editor.tsx
CHANGED
|
@@ -72,6 +72,15 @@ const Editor = ({
|
|
|
72
72
|
const [isCodeView, setIsCodeView] = useState(false); // 코드보기 모드
|
|
73
73
|
const [codeContent, setCodeContent] = useState(''); // 코드보기 내용
|
|
74
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
|
+
|
|
75
84
|
const editorRef = useRef<HTMLDivElement>(null);
|
|
76
85
|
const codeEditorRef = useRef<HTMLTextAreaElement>(null);
|
|
77
86
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -227,6 +236,106 @@ const Editor = ({
|
|
|
227
236
|
setCurrentParagraphStyle('p');
|
|
228
237
|
};
|
|
229
238
|
|
|
239
|
+
// ref와 state 동기화
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
historyRef.current = history;
|
|
242
|
+
historyIndexRef.current = historyIndex;
|
|
243
|
+
}, [history, historyIndex]);
|
|
244
|
+
|
|
245
|
+
// 히스토리에 추가 (디바운스 적용)
|
|
246
|
+
const addToHistory = useCallback((content: string) => {
|
|
247
|
+
// 기존 타이머 취소
|
|
248
|
+
if (historyTimerRef.current) {
|
|
249
|
+
clearTimeout(historyTimerRef.current);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 500ms 후에 히스토리 추가 (연속 입력 시 하나로 묶음)
|
|
253
|
+
historyTimerRef.current = setTimeout(() => {
|
|
254
|
+
// ref에서 최신 값 가져오기
|
|
255
|
+
const currentHistory = historyRef.current;
|
|
256
|
+
const currentIndex = historyIndexRef.current;
|
|
257
|
+
|
|
258
|
+
// 현재 인덱스 이후의 히스토리 제거
|
|
259
|
+
const newHistory = currentHistory.slice(0, currentIndex + 1);
|
|
260
|
+
|
|
261
|
+
// 마지막 항목과 동일하면 추가하지 않음
|
|
262
|
+
if (newHistory[newHistory.length - 1] === content) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 새 항목 추가 (최대 200개)
|
|
267
|
+
const updated = [...newHistory, content];
|
|
268
|
+
if (updated.length > 200) {
|
|
269
|
+
updated.shift(); // 가장 오래된 항목 제거
|
|
270
|
+
setHistory(updated);
|
|
271
|
+
setHistoryIndex(currentIndex); // 인덱스는 그대로 유지
|
|
272
|
+
} else {
|
|
273
|
+
setHistory(updated);
|
|
274
|
+
setHistoryIndex(updated.length - 1);
|
|
275
|
+
}
|
|
276
|
+
}, 500);
|
|
277
|
+
}, []);
|
|
278
|
+
|
|
279
|
+
// Undo 실행
|
|
280
|
+
const performUndo = useCallback(() => {
|
|
281
|
+
// debounce 타이머 취소 (undo 중에는 히스토리 추가 안 함)
|
|
282
|
+
if (historyTimerRef.current) {
|
|
283
|
+
clearTimeout(historyTimerRef.current);
|
|
284
|
+
historyTimerRef.current = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const currentIndex = historyIndexRef.current;
|
|
288
|
+
const currentHistory = historyRef.current;
|
|
289
|
+
|
|
290
|
+
if (currentIndex > 0) {
|
|
291
|
+
const newIndex = currentIndex - 1;
|
|
292
|
+
const content = currentHistory[newIndex];
|
|
293
|
+
setHistoryIndex(newIndex);
|
|
294
|
+
|
|
295
|
+
if (editorRef.current) {
|
|
296
|
+
isUndoRedoRef.current = true; // undo 실행 중 플래그 설정
|
|
297
|
+
editorRef.current.innerHTML = content;
|
|
298
|
+
onChange(content);
|
|
299
|
+
detectCurrentParagraphStyle();
|
|
300
|
+
detectCurrentAlign();
|
|
301
|
+
// 다음 틱에서 플래그 해제
|
|
302
|
+
setTimeout(() => {
|
|
303
|
+
isUndoRedoRef.current = false;
|
|
304
|
+
}, 0);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}, [onChange]);
|
|
308
|
+
|
|
309
|
+
// Redo 실행
|
|
310
|
+
const performRedo = useCallback(() => {
|
|
311
|
+
// debounce 타이머 취소 (redo 중에는 히스토리 추가 안 함)
|
|
312
|
+
if (historyTimerRef.current) {
|
|
313
|
+
clearTimeout(historyTimerRef.current);
|
|
314
|
+
historyTimerRef.current = null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const currentIndex = historyIndexRef.current;
|
|
318
|
+
const currentHistory = historyRef.current;
|
|
319
|
+
|
|
320
|
+
if (currentIndex < currentHistory.length - 1) {
|
|
321
|
+
const newIndex = currentIndex + 1;
|
|
322
|
+
const content = currentHistory[newIndex];
|
|
323
|
+
setHistoryIndex(newIndex);
|
|
324
|
+
|
|
325
|
+
if (editorRef.current) {
|
|
326
|
+
isUndoRedoRef.current = true; // redo 실행 중 플래그 설정
|
|
327
|
+
editorRef.current.innerHTML = content;
|
|
328
|
+
onChange(content);
|
|
329
|
+
detectCurrentParagraphStyle();
|
|
330
|
+
detectCurrentAlign();
|
|
331
|
+
// 다음 틱에서 플래그 해제
|
|
332
|
+
setTimeout(() => {
|
|
333
|
+
isUndoRedoRef.current = false;
|
|
334
|
+
}, 0);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}, [onChange]);
|
|
338
|
+
|
|
230
339
|
const handleInput = useCallback(() => {
|
|
231
340
|
if (editorRef.current) {
|
|
232
341
|
const content = editorRef.current.innerHTML;
|
|
@@ -235,11 +344,150 @@ const Editor = ({
|
|
|
235
344
|
validateHandler(content);
|
|
236
345
|
detectCurrentParagraphStyle();
|
|
237
346
|
detectCurrentAlign();
|
|
347
|
+
|
|
348
|
+
// 히스토리에 추가
|
|
349
|
+
addToHistory(content);
|
|
238
350
|
}
|
|
239
351
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
240
|
-
}, [onChange]);
|
|
352
|
+
}, [onChange, addToHistory]);
|
|
353
|
+
|
|
354
|
+
// 붙여넣기 이벤트 핸들러 - 지원하지 않는 스타일 제거
|
|
355
|
+
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
|
|
358
|
+
const clipboardData = e.clipboardData;
|
|
359
|
+
if (!clipboardData) return;
|
|
360
|
+
|
|
361
|
+
// HTML 데이터 가져오기
|
|
362
|
+
const html = clipboardData.getData('text/html');
|
|
363
|
+
const text = clipboardData.getData('text/plain');
|
|
364
|
+
|
|
365
|
+
if (html) {
|
|
366
|
+
// 임시 div를 만들어서 HTML 파싱
|
|
367
|
+
const tempDiv = document.createElement('div');
|
|
368
|
+
tempDiv.innerHTML = html;
|
|
369
|
+
|
|
370
|
+
// 지원하는 태그와 스타일 정의
|
|
371
|
+
const allowedTags = ['P', 'BR', 'STRONG', 'B', 'EM', 'I', 'U', 'S', 'STRIKE', 'DEL',
|
|
372
|
+
'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE',
|
|
373
|
+
'UL', 'OL', 'LI', 'A', 'IMG', 'SPAN', 'DIV'];
|
|
374
|
+
const allowedStyles = ['color', 'background-color', 'text-align'];
|
|
375
|
+
|
|
376
|
+
// 모든 요소를 순회하면서 정리
|
|
377
|
+
const cleanElement = (element: Element): Node | null => {
|
|
378
|
+
const tagName = element.tagName;
|
|
379
|
+
|
|
380
|
+
// 지원하지 않는 태그는 내용만 유지
|
|
381
|
+
if (!allowedTags.includes(tagName)) {
|
|
382
|
+
const fragment = document.createDocumentFragment();
|
|
383
|
+
Array.from(element.childNodes).forEach(child => {
|
|
384
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
385
|
+
const cleaned = cleanElement(child as Element);
|
|
386
|
+
if (cleaned) fragment.appendChild(cleaned);
|
|
387
|
+
} else if (child.nodeType === Node.TEXT_NODE) {
|
|
388
|
+
fragment.appendChild(child.cloneNode(true));
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
return fragment.childNodes.length > 0 ? fragment : null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 지원하는 태그는 복제하고 스타일 정리
|
|
395
|
+
const newElement = element.cloneNode(false) as HTMLElement;
|
|
396
|
+
|
|
397
|
+
// 모든 속성 제거 후 필요한 것만 복원
|
|
398
|
+
const attrs = Array.from(element.attributes);
|
|
399
|
+
attrs.forEach(attr => newElement.removeAttribute(attr.name));
|
|
400
|
+
|
|
401
|
+
// href, src, alt 등 필수 속성만 복원
|
|
402
|
+
if (tagName === 'A' && element.getAttribute('href')) {
|
|
403
|
+
newElement.setAttribute('href', element.getAttribute('href')!);
|
|
404
|
+
}
|
|
405
|
+
if (tagName === 'IMG') {
|
|
406
|
+
if (element.getAttribute('src')) {
|
|
407
|
+
newElement.setAttribute('src', element.getAttribute('src')!);
|
|
408
|
+
}
|
|
409
|
+
if (element.getAttribute('alt')) {
|
|
410
|
+
newElement.setAttribute('alt', element.getAttribute('alt')!);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 스타일 복원 (허용된 것만)
|
|
415
|
+
if (element instanceof HTMLElement && element.style) {
|
|
416
|
+
allowedStyles.forEach(styleName => {
|
|
417
|
+
const value = element.style.getPropertyValue(styleName);
|
|
418
|
+
if (value) {
|
|
419
|
+
(newElement as HTMLElement).style.setProperty(styleName, value);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// 자식 요소 처리
|
|
425
|
+
Array.from(element.childNodes).forEach(child => {
|
|
426
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
427
|
+
const cleaned = cleanElement(child as Element);
|
|
428
|
+
if (cleaned) newElement.appendChild(cleaned);
|
|
429
|
+
} else if (child.nodeType === Node.TEXT_NODE) {
|
|
430
|
+
newElement.appendChild(child.cloneNode(true));
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
return newElement;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
// 정리된 HTML 생성
|
|
438
|
+
const cleanedDiv = document.createElement('div');
|
|
439
|
+
Array.from(tempDiv.childNodes).forEach(child => {
|
|
440
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
441
|
+
const cleaned = cleanElement(child as Element);
|
|
442
|
+
if (cleaned) cleanedDiv.appendChild(cleaned);
|
|
443
|
+
} else if (child.nodeType === Node.TEXT_NODE) {
|
|
444
|
+
cleanedDiv.appendChild(child.cloneNode(true));
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// 현재 커서 위치에 삽입
|
|
449
|
+
const selection = window.getSelection();
|
|
450
|
+
if (selection && selection.rangeCount > 0) {
|
|
451
|
+
const range = selection.getRangeAt(0);
|
|
452
|
+
range.deleteContents();
|
|
453
|
+
|
|
454
|
+
const fragment = document.createDocumentFragment();
|
|
455
|
+
while (cleanedDiv.firstChild) {
|
|
456
|
+
fragment.appendChild(cleanedDiv.firstChild);
|
|
457
|
+
}
|
|
458
|
+
range.insertNode(fragment);
|
|
459
|
+
|
|
460
|
+
// 커서를 삽입된 내용의 끝으로 이동
|
|
461
|
+
range.collapse(false);
|
|
462
|
+
selection.removeAllRanges();
|
|
463
|
+
selection.addRange(range);
|
|
464
|
+
}
|
|
465
|
+
} else if (text) {
|
|
466
|
+
// HTML이 없으면 일반 텍스트 삽입
|
|
467
|
+
const selection = window.getSelection();
|
|
468
|
+
if (selection && selection.rangeCount > 0) {
|
|
469
|
+
const range = selection.getRangeAt(0);
|
|
470
|
+
range.deleteContents();
|
|
471
|
+
range.insertNode(document.createTextNode(text));
|
|
472
|
+
range.collapse(false);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// 변경사항 반영
|
|
477
|
+
handleInput();
|
|
478
|
+
}, [handleInput]);
|
|
241
479
|
|
|
242
480
|
const execCommand = (command: string, value: string | undefined = undefined) => {
|
|
481
|
+
// undo/redo는 커스텀 함수 사용
|
|
482
|
+
if (command === 'undo') {
|
|
483
|
+
performUndo();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (command === 'redo') {
|
|
487
|
+
performRedo();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
243
491
|
// bold, italic, underline, strikeThrough일 때 선택 영역이 없으면 아무것도 하지 않음
|
|
244
492
|
if (['bold', 'italic', 'underline', 'strikeThrough'].includes(command)) {
|
|
245
493
|
const selection = window.getSelection();
|
|
@@ -1929,6 +2177,18 @@ const Editor = ({
|
|
|
1929
2177
|
};
|
|
1930
2178
|
}, [selectedYoutube]);
|
|
1931
2179
|
|
|
2180
|
+
// 외부에서 value가 변경되면 히스토리 초기화 (단, undo/redo 중에는 제외)
|
|
2181
|
+
useEffect(() => {
|
|
2182
|
+
if (isUndoRedoRef.current) {
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
if (editorRef.current && editorRef.current.innerHTML !== value) {
|
|
2186
|
+
editorRef.current.innerHTML = value;
|
|
2187
|
+
setHistory([value]);
|
|
2188
|
+
setHistoryIndex(0);
|
|
2189
|
+
}
|
|
2190
|
+
}, [value]);
|
|
2191
|
+
|
|
1932
2192
|
// 초기 로드 시 문단 형식 감지 (기본 p 태그는 추가하지 않음)
|
|
1933
2193
|
useEffect(() => {
|
|
1934
2194
|
// 약간의 지연을 주어 DOM이 완전히 렌더링된 후 감지
|
|
@@ -1951,7 +2211,14 @@ const Editor = ({
|
|
|
1951
2211
|
type="button"
|
|
1952
2212
|
className={styles.toolbarButton}
|
|
1953
2213
|
onClick={() => execCommand('undo')}
|
|
2214
|
+
disabled={historyIndex <= 0}
|
|
1954
2215
|
title="실행 취소"
|
|
2216
|
+
style={{
|
|
2217
|
+
opacity: historyIndex <= 0 ? 0.3 : 1,
|
|
2218
|
+
backgroundColor: 'transparent',
|
|
2219
|
+
border: 'none',
|
|
2220
|
+
cursor: historyIndex <= 0 ? 'not-allowed' : 'pointer'
|
|
2221
|
+
}}
|
|
1955
2222
|
>
|
|
1956
2223
|
<i className={styles.undo} />
|
|
1957
2224
|
</button>
|
|
@@ -1959,7 +2226,14 @@ const Editor = ({
|
|
|
1959
2226
|
type="button"
|
|
1960
2227
|
className={styles.toolbarButton}
|
|
1961
2228
|
onClick={() => execCommand('redo')}
|
|
2229
|
+
disabled={historyIndex >= history.length - 1}
|
|
1962
2230
|
title="다시 실행"
|
|
2231
|
+
style={{
|
|
2232
|
+
opacity: historyIndex >= history.length - 1 ? 0.3 : 1,
|
|
2233
|
+
backgroundColor: 'transparent',
|
|
2234
|
+
border: 'none',
|
|
2235
|
+
cursor: historyIndex >= history.length - 1 ? 'not-allowed' : 'pointer'
|
|
2236
|
+
}}
|
|
1963
2237
|
>
|
|
1964
2238
|
<i className={styles.redo} />
|
|
1965
2239
|
</button>
|
|
@@ -2686,6 +2960,7 @@ const Editor = ({
|
|
|
2686
2960
|
className={styles.editorContent}
|
|
2687
2961
|
contentEditable
|
|
2688
2962
|
onInput={handleInput}
|
|
2963
|
+
onPaste={handlePaste}
|
|
2689
2964
|
onClick={handleEditorClick}
|
|
2690
2965
|
onKeyUp={() => {
|
|
2691
2966
|
detectCurrentParagraphStyle();
|
package/react.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import Input from './react/atom/input';
|
|
2
2
|
import Textarea from './react/atom/textarea';
|
|
3
3
|
import Editor from './react/atom/editor';
|
|
4
|
-
import
|
|
4
|
+
import EditorView from './react/atom/editor-view';
|
|
5
|
+
import Pagination from './react/molecule/pagination';
|
|
5
6
|
import Field from './react/molecule/field';
|
|
6
7
|
const Form = {
|
|
7
8
|
Input,
|
|
8
9
|
Textarea,
|
|
9
10
|
Editor,
|
|
11
|
+
EditorView,
|
|
10
12
|
Field,
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
export default Form;
|
|
14
16
|
|
|
15
|
-
export { Input, Textarea, Editor, Pagination, Field };
|
|
17
|
+
export { Input, Textarea, Editor, EditorView, Pagination, Field };
|
|
File without changes
|
|
File without changes
|