index.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. 'use client';
  2. import { useEffect, useRef } from 'react';
  3. import { Rnd } from 'react-rnd';
  4. import Quill from 'quill';
  5. import 'quill/dist/quill.snow.css';
  6. import '@/styles/quill.scss';
  7. import ReactDOM from 'react-dom/client';
  8. import FileUploader from './FileUploader';
  9. import FileBlot from './FileBlot';
  10. const BlockEmbed = Quill.import('blots/block/embed');
  11. // --------- FileUploader ---------
  12. class ResizableImageBlot extends BlockEmbed {
  13. static blotName = 'resizableImage';
  14. static tagName = 'div';
  15. static className = 'resizable-image-container';
  16. static create(value) {
  17. // value: { url: string, width?: number, height?: number }
  18. const node = super.create();
  19. // 저장할 이미지 정보
  20. node.setAttribute('data-url', value.url);
  21. node.setAttribute('data-width', value.width || 200);
  22. node.setAttribute('data-height', value.height || 200);
  23. // React 컴포넌트를 렌더링할 컨테이너 생성
  24. const container = document.createElement('div');
  25. container.classList.add('resizable-image-inner');
  26. node.appendChild(container);
  27. return node;
  28. }
  29. static value(node) {
  30. // 블롯의 값을 Delta로 저장할 때 사용
  31. return {
  32. url: node.getAttribute('data-url'),
  33. width: node.getAttribute('data-width'),
  34. height: node.getAttribute('data-height')
  35. };
  36. }
  37. renderReactComponent() {
  38. const node = this.domNode as HTMLElement;
  39. const container = node.querySelector('.resizable-image-inner');
  40. const initialWidth = parseInt(node.getAttribute('data-width') || '200', 10);
  41. const initialHeight = parseInt(node.getAttribute('data-height') || '200', 10);
  42. const url = node.getAttribute('data-url') || '';
  43. // React 18 이상에서는 createRoot 사용
  44. const root = ReactDOM.createRoot(container!);
  45. root.render(
  46. <Rnd
  47. default={{
  48. width: initialWidth,
  49. height: initialHeight,
  50. }}
  51. bounds="parent"
  52. enableResizing={{
  53. top: true,
  54. right: true,
  55. bottom: true,
  56. left: true,
  57. topRight: true,
  58. bottomRight: true,
  59. bottomLeft: true,
  60. topLeft: true,
  61. }}
  62. onResizeStop={(e, direction, ref, delta, position) => {
  63. // 리사이즈 완료 후 새로운 크기를 data-속성에 저장
  64. node.setAttribute('data-width', String(ref.offsetWidth));
  65. node.setAttribute('data-height', String(ref.offsetHeight));
  66. }}
  67. >
  68. <img
  69. src={url}
  70. alt=""
  71. style={{ width: '100%', height: '100%', objectFit: 'contain' }}
  72. />
  73. </Rnd>
  74. );
  75. }
  76. }
  77. Quill.register(ResizableImageBlot);
  78. Quill.register('modules/fileUploader', FileUploader);
  79. Quill.register(FileBlot);
  80. // --------- Quill Editor ---------
  81. // 글씨 크기 설정
  82. const FontSizeStyle = Quill.import('attributors/style/size') as any;
  83. FontSizeStyle.whitelist = ['11px', '13px', '15px', '16px', '19px', '24px', '28px', '30px', '34px', '38px'];
  84. Quill.register(FontSizeStyle, true);
  85. interface QuillEditorProps {
  86. content: string|null|'';
  87. onChange: (content: string) => void;
  88. }
  89. export default function Editor({ content, onChange }: QuillEditorProps) {
  90. const editorRef = useRef<HTMLDivElement | null>(null);
  91. const quillInstanceRef = useRef<Quill | null>(null);
  92. useEffect(() => {
  93. if (editorRef.current && !quillInstanceRef.current) {
  94. const icons = Quill.import('ui/icons') as Record<string, string>;
  95. // 파일 아이콘 생성
  96. icons['file'] = '<svg viewBox="0 -0.5 17 17" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="si-glyph si-glyph-file-upload" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <title>1126</title> <defs> </defs> <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g transform="translate(1.000000, 1.000000)" fill="#434343"> <path d="M14,8.047 L14,12.047 L2,12.047 L2,8.047 L0,8.047 L0,15 L15.969,15 L15.969,8.047 L14,8.047 Z" class="si-glyph-fill"> </path> <path d="M7.997,0 L5,3.963 L7.016,3.984 L7.016,8.969 L8.953,8.969 L8.953,3.984 L10.953,3.984 L7.997,0 Z" class="si-glyph-fill"> </path> </g> </g> </g></svg>';
  97. quillInstanceRef.current = new Quill(editorRef.current, {
  98. theme: 'snow',
  99. modules: {
  100. toolbar: {
  101. container: [
  102. [{ font: [] }, { size: ['11px', '13px', '15px', '16px', '19px', '24px', '28px', '30px', '34px', '38px']}],
  103. ['bold', 'italic', 'underline', 'strike', { color: [] }, { background: [] }],
  104. [{ align: [] }, { list: 'ordered' }, { list: 'bullet' }],
  105. ['blockquote', 'code-block', 'clean'],
  106. ['link', 'image', 'video', 'file']
  107. ],
  108. handlers: {
  109. file: () => {
  110. const uploader = quillInstanceRef.current?.getModule('fileUploader') as FileUploader;
  111. uploader?.selectFile();
  112. }
  113. },
  114. },
  115. clipboard: {
  116. matchVisual: false,
  117. },
  118. fileUploader: {}
  119. }
  120. });
  121. quillInstanceRef.current.root.innerHTML = content || '';
  122. // 에디터 내용 변경 시
  123. quillInstanceRef.current.on('text-change', () => {
  124. onChange(quillInstanceRef.current!.root.innerHTML);
  125. });
  126. // 첨부파일 클릭 시 활성화 제거
  127. quillInstanceRef.current.on('selection-change', () => {
  128. const uploader = quillInstanceRef.current?.getModule('fileUploader') as FileUploader;
  129. if (uploader) {
  130. uploader.clearActiveCards();
  131. }
  132. });
  133. // FileCardBlot에 quillInstanceRef.current를 주입
  134. FileBlot.setQuillInstance(quillInstanceRef.current);
  135. const quillRoot = quillInstanceRef.current.root;
  136. // drop 위치 처리
  137. quillRoot.addEventListener('dragover', (e) => {
  138. e.preventDefault();
  139. });
  140. quillRoot.addEventListener('drop', (e) => {
  141. e.preventDefault();
  142. const data = e.dataTransfer?.getData('text/plain');
  143. if (!data) return;
  144. const file = JSON.parse(data);
  145. // 현재 커서 위치 찾기
  146. const selection = quillInstanceRef.current!.getSelection();
  147. const index = selection ? selection.index : quillInstanceRef.current!.getLength();
  148. // 기존 dragging 된 요소 삭제
  149. const draggingNode = quillRoot.querySelector('.file-card.dragging');
  150. draggingNode?.remove();
  151. // 새로운 위치에 삽입
  152. quillInstanceRef.current!.insertEmbed(index, 'fileCard', file, Quill.sources.USER);
  153. quillInstanceRef.current!.setSelection(index + 1);
  154. });
  155. }
  156. }, []);
  157. useEffect(() => {
  158. if (quillInstanceRef.current) {
  159. const currentHTML = quillInstanceRef.current.root.innerHTML;
  160. if (currentHTML !== (content || '')) {
  161. quillInstanceRef.current.root.innerHTML = content || '';
  162. }
  163. }
  164. }, [content]);
  165. return (
  166. <div className="w-full">
  167. <div ref={editorRef} />
  168. </div>
  169. );
  170. }