import { Api } from './api.js'; import { UI } from './ui.js'; /** * editor.js - Quill 에디터 및 미디어 업로드 관리 */ let quillInstance = null; // 모듈 내부에서만 사용하는 Quill 인스턴스 export let Editor = { /** * 에디터 초기화 함수 * @param {boolean} useEditor - 편집 모드 여부 (true: 편집, false: 읽기) * @param {object} baseData - 게시물 데이터 (제목, 내용 등) */ init(useEditor = false, baseData = {}) { console.log(`### Editor Init (EditMode: ${useEditor}) ###`); const editorContainer = document.querySelector('#editor'); if (!editorContainer) return; // 1. 수동 입력 필드 초기화 (날짜, 좌표) this.initManualFields(baseData); // 2. Quill 모듈 및 포맷 등록 this.registerQuillModules(); // 3. Quill 옵션 설정 const quillOptions = { theme: 'snow', modules: useEditor ? { imageResize: { displaySize: true }, toolbar: { container: [ [{ font: [] }, { size: ['small', false, 'large', 'huge'] }], ['bold', 'italic', 'underline', 'strike'], [{ color: [] }, { background: [] }], [{ header: 1 }, { header: 2 }, 'blockquote', 'code-block'], [{ list: 'ordered'}, { list: 'bullet' }], [{ align: [] }], ['table-better'], ['clean'], ['link', 'image', 'video'] ], handlers: { image: () => this.selectLocalImage(), video: () => this.selectLocalVideo() } }, 'table-better': { language: 'en_US', toolbarTable: true }, keyboard: { bindings: QuillTableBetter.keyboardBindings } } : { imageResize: { displaySize: true }, toolbar: false // 읽기 모드 }, readOnly: !useEditor }; // 4. Quill 인스턴스 생성 quillInstance = new Quill(editorContainer, quillOptions); // 5. 초기 콘텐츠 로드 if (baseData.content) { this.loadContent(baseData.content); } // 6. UI 스타일 처리 (Sticky Toolbar, ReadOnly Class) this.setupEditorUI(editorContainer, useEditor, baseData.title); // 7. 붙여넣기 핸들러 (Markdown 지원 등) this.setupPasteHandler(); }, /** * Quill 인스턴스 반환 (저장 시 사용) */ getCookies() { return quillInstance ? quillInstance.getContents() : null; }, /** * 로컬 이미지 선택 및 업로드 핸들러 */ selectLocalImage() { const url = prompt("이미지 URL을 입력하거나 취소하여 파일을 업로드하세요."); if (url) { this.insertToEditor('image', url); } else { const input = document.createElement('input'); input.setAttribute('type', 'file'); input.setAttribute('accept', 'image/*'); input.click(); input.onchange = () => { const file = input.files[0]; if (file) { if (!file.type.startsWith('image/')) { UI.showAlert('알림', '이미지 파일만 업로드 가능합니다.'); return; } this.uploadMedia(file, 'image'); } }; } }, /** * 로컬 비디오 선택 및 업로드 핸들러 */ selectLocalVideo() { const input = document.createElement('input'); input.setAttribute('type', 'file'); input.setAttribute('accept', 'video/*'); input.click(); input.onchange = () => { const file = input.files[0]; if (file) { if (!file.type.startsWith('video/')) { UI.showAlert('알림', '동영상 파일만 업로드 가능합니다.'); return; } this.uploadMedia(file, 'video'); // 비디오 업로드 로직은 서버 API 필요 } }; }, /** * 파일 업로드 및 에디터 삽입 (이미지/비디오 공용) */ async uploadMedia(file, type) { const formData = new FormData(); const uploadUrl = type === 'image' ? `${Api.getMainPath()}/api/images/upload` : `${Api.getMainPath()}/api/upload/video`; // 비디오용 경로는 필요시 수정 formData.append('file', file); // 서버 파라미터명 'file' try { // Api 모듈을 사용하여 업로드 (CSRF 토큰 자동 처리) const data = await Api.request(uploadUrl, 'POST', formData); if (data.fileName) { // 이미지 경로 구성 (/api/images/파일명) const mediaUrl = `${Api.getMainPath()}/api/images/${data.fileName}`; this.insertToEditor(type, mediaUrl); } else { throw new Error('Upload successful but no filename returned'); } } catch (error) { console.error(`${type} upload failed:`, error); UI.showAlert('오류', `${type} 업로드에 실패했습니다.`); } }, insertToEditor(type, url) { if (!quillInstance) return; const range = quillInstance.getSelection(true); quillInstance.insertEmbed(range.index, type, url); quillInstance.setSelection(range.index + 1); }, loadContent(content) { try { const delta = JSON.parse(content); if (delta && Array.isArray(delta.ops)) { quillInstance.setContents(delta); return; } } catch (e) {} // JSON 파싱 실패 시 HTML로 로드 quillInstance.clipboard.dangerouslyPasteHTML(content); }, // --- 내부 헬퍼 함수들 --- initManualFields(baseData) { const dateInput = document.getElementById('manual_date'); const latInput = document.getElementById('manual_lat'); const lonInput = document.getElementById('manual_lon'); if (dateInput) { const time = baseData.writeTime > 0 ? baseData.writeTime : Date.now(); dateInput.value = new Date(time - (new Date().getTimezoneOffset() * 60000)).toISOString().slice(0, 16); } if (latInput && lonInput) { latInput.value = baseData.modifyLat || baseData.firstPostLat || ''; lonInput.value = baseData.modifyLon || baseData.firstPostLon || ''; } }, registerQuillModules() { const Font = Quill.import('formats/font'); Font.whitelist = ['monospace', 'sans-serif', 'serif', 'arial', 'georgia', 'comic-sans-ms', 'courier-new', 'roboto', 'playfair-display']; Quill.register(Font, true); if (typeof QuillTableBetter !== 'undefined') { Quill.register({ 'modules/table-better': QuillTableBetter }, true); } if (typeof QuillResizeModule !== 'undefined') { Quill.register('modules/imageResize', QuillResizeModule); } // Custom Image Blot (style 속성 지원) const ImageBlot = Quill.import('formats/image'); class StyledImage extends ImageBlot { static formats(domNode) { const formats = super.formats(domNode); if (domNode.hasAttribute('style')) formats.style = domNode.getAttribute('style'); return formats; } format(name, value) { if (name === 'style') { value ? this.domNode.setAttribute(name, value) : this.domNode.removeAttribute(name); } else { super.format(name, value); } } } Quill.register(StyledImage, true); }, setupEditorUI(container, useEditor, title) { if (useEditor) { container.classList.remove('readonly-mode'); const toolbar = document.querySelector('.ql-toolbar'); window.addEventListener('scroll', () => { if (window.scrollY > container.offsetTop) { toolbar.classList.add('sticky'); container.classList.add('has-sticky-toolbar'); } else { toolbar.classList.remove('sticky'); container.classList.remove('has-sticky-toolbar'); } }); // 제목 필드 설정 const titleField = document.querySelector("#title_field"); if (titleField) titleField.value = title || ''; } else { container.classList.add('readonly-mode'); } }, setupPasteHandler() { quillInstance.root.addEventListener('paste', (event) => { const pasteText = (event.clipboardData || window.clipboardData).getData('text'); // 마크다운 감지 if (/^(#|\*|-|>|`)/.test(pasteText.trim()) && typeof marked !== 'undefined') { event.preventDefault(); const html = marked.parse(pasteText, { gfm: true, breaks: true }); const range = quillInstance.getSelection(true); quillInstance.clipboard.dangerouslyPasteHTML(range.index, html); } }, true); } };