Editor.tsx 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. 'use client';
  2. /**
  3. * @author: Kim Jino
  4. * @since: 2025.03.31
  5. * @description: CKEditor component for Next.js using Script component for loading CKEditor script
  6. */
  7. import '@/styles/editor.scss';
  8. import Script from 'next/script';
  9. import React, { useRef, useState, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
  10. import { CKEditor } from '@ckeditor/ckeditor5-react';
  11. import type { Editor as CKEditorInstance } from 'ckeditor5';
  12. import Loading from '@/app/component/Loading';
  13. import BoardMeta from '@/types/forum/boardMeta';
  14. import { PostConst } from '@/constants/forum';
  15. export interface Handle {
  16. editorInstance: CKEditorInstance|null;
  17. getFileStore(): UploadedFile[];
  18. getImageStore(): UploadedImage[];
  19. getMediaStore(): UploadedMedia[];
  20. }
  21. interface Props {
  22. editorKey: string;
  23. data: string;
  24. onChange: (data: string) => void;
  25. boardMeta?: BoardMeta|null;
  26. }
  27. const Editor = forwardRef<Handle, Props>(({ editorKey = '', data = '', onChange, boardMeta }: Props, ref) =>
  28. {
  29. const [ready, setReady] = useState<boolean>(false); // CKEditor가 준비되었는지 여부
  30. const [instanceLoaded, setInstanceLoaded] = useState<boolean>(false);
  31. const editorRef = useRef<typeof window.Editor|null>(null); // CKEditor 객체
  32. const editorInstanceRef = useRef<CKEditorInstance|null>(null);
  33. useEffect(() => {
  34. if (typeof window !== 'undefined' && window.Editor) {
  35. editorRef.current = window.Editor;
  36. setReady(true);
  37. }
  38. }, [editorKey]); // 게시판 주소가 바뀌면 다시 ready 확인
  39. const handleScriptLoad = () => {
  40. if (typeof window !== 'undefined' && window.Editor) {
  41. editorRef.current = window.Editor;
  42. setReady(true);
  43. } else {
  44. console.error('CKEditor script not loaded properly.');
  45. }
  46. };
  47. // 외부에 접근 가능한 명령어
  48. useImperativeHandle(ref, () => ({
  49. get editorInstance() { return editorInstanceRef.current; },
  50. getFileStore: () => editorInstanceRef.current?._fileStore || [],
  51. getImageStore: () => editorInstanceRef.current?._imageStore || [],
  52. getMediaStore: () => editorInstanceRef.current?._mediaStore || []
  53. }), [instanceLoaded]);
  54. const editorConfig = useMemo(() => ({
  55. placeholder: '내용을 입력하세요.',
  56. maxWordCount: PostConst.MaxAllowedContentLength, // 최대 본문 길이
  57. // 이미지 설정
  58. allowImage: boardMeta?.write.allowImage ?? false,
  59. imageUploadLimit: boardMeta?.write.imageUploadLimit,
  60. imageUploadMaxSize: boardMeta?.write.imageUploadMaxSize,
  61. // 동영상 설정
  62. allowMedia: boardMeta?.write.allowMedia ?? false,
  63. mediaUploadLimit: boardMeta?.write.mediaUploadLimit,
  64. // 파일 설정
  65. allowFile: boardMeta?.write.allowFile ?? false,
  66. fileUploadLimit: boardMeta?.write.fileUploadLimit,
  67. fileUploadMaxSize: boardMeta?.write.fileUploadMaxSize,
  68. fileUploadExtension: boardMeta?.write.fileUploadExtension
  69. /*
  70. // 글자수 세기
  71. wordCount: {
  72. onUpdate: (stats: { characters: number; words: number }) => {
  73. document.getElementById('editorTxtLength')!.innerText = stats.characters.toString();
  74. }
  75. }
  76. */
  77. }), []);
  78. const handleOnReady = useCallback((editor: CKEditorInstance) => {
  79. console.log('Editor was initialized');
  80. // 최소 높이 설정
  81. editor.editing.view.change(writer => {
  82. const root = editor.editing.view.document.getRoot();
  83. if (root) {
  84. writer.setStyle('min-height', '300px', root);
  85. }
  86. });
  87. editorInstanceRef.current = editor;
  88. setInstanceLoaded(true);
  89. // 외부 내용을 복사 붙여넣기 가능하도록 처리
  90. editor.editing.view.document.on('paste', (_, data) => {
  91. const html = data.dataTransfer.getData('text/html');
  92. const text = data.dataTransfer.getData('text/plain');
  93. const content = (html || text);
  94. if (content) {
  95. const viewFragment = editor.data.processor.toView(content);
  96. const modelFragment = editor.data.toModel(viewFragment);
  97. editor.model.change(() => {
  98. editor.model.insertContent(modelFragment);
  99. });
  100. }
  101. });
  102. }, []);
  103. const handleChange = useCallback((_: unknown, editor: CKEditorInstance) => {
  104. onChange(editor.getData()); // 불필요한 optional chaining 제거
  105. }, [onChange]);
  106. return (
  107. <>
  108. <Script src="/editor/editor.min.js" strategy="afterInteractive" onLoad={handleScriptLoad}/>
  109. {ready && editorRef.current ? (
  110. <CKEditor
  111. editor={editorRef.current}
  112. data={data}
  113. config={editorConfig}
  114. onReady={handleOnReady}
  115. onChange={handleChange}
  116. />
  117. ) : (
  118. <Loading/>
  119. )}
  120. </>
  121. );
  122. });
  123. Editor.displayName = 'Editor';
  124. export default Editor;