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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "podo-ui",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "author": "hada0127 <work@tarucy.net>",
6
6
  "license": "MIT",
@@ -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;
@@ -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 Pagination from './react/atom/pagination';
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