/** * Summernote Custom JavaScript * - 에디터 초기화, 이미지 처리, 붙여넣기 처리 등 */ /** * 이미지 다이얼로그에 안내 문구 추가 * @param {jQuery} $modal - 모달 jQuery 객체 */ function addImageUploadGuide($modal) { // 이미 안내 문구가 있으면 무시 if ($modal.find('.image-upload-guide').length) return; var guideHtml = '
' + 'info' + '지원 형식: JPG, PNG, GIF, BMP, WEBP / 최대 크기: 10MB' + '
'; // summernote-lite 다이얼로그 구조: .note-modal > .note-modal-content > .note-modal-body var $modalBody = $modal.find('.note-modal-body'); if ($modalBody.length) { $modalBody.prepend(guideHtml); } else { // 대체 위치: 모달 콘텐츠의 첫 번째 자식 앞에 삽입 var $modalContent = $modal.find('.note-modal-content'); if ($modalContent.length) { $modalContent.find('.note-modal-header').after(guideHtml); } } } /** * Summernote 에디터 공통 초기화 함수 * - 기본: data-uri(Base64) 방식으로 이미지 처리 * - useFileUpload: true 옵션 시 서버 업로드 방식 사용 * - 이미지 리사이징 지원 * @param {string} selector - 에디터 selector (예: '#contents') * @param {object} customOptions - 추가 옵션 * - useFileUpload: true면 서버 업로드 방식, false면 data-uri 방식 (기본값: false) * - uploadUrl: 이미지 업로드 API URL (useFileUpload: true 시 필수) * - imageViewUrl: 이미지 서빙 API URL (useFileUpload: true 시 필수) */ function initSummernote(selector, customOptions) { customOptions = customOptions || {}; var useFileUpload = customOptions.useFileUpload || false; // 기본 설정 var defaultOptions = { placeholder: customOptions.placeholder || '여기에 내용을 입력하세요.', height: customOptions.height || 300, lang: 'ko-KR', toolbar: customOptions.toolbar || [ ['font', ['bold', 'underline', 'clear']], ['fontsize', ['fontsize']], ['color', ['color']], ['para', ['ul', 'ol', 'paragraph']], ['table', ['table']], ['insert', ['link', 'picture']], ['view', ['fullscreen', 'codeview', 'help']] ], fontSizes: ['8', '9', '10', '11', '12', '14', '16', '18', '20', '24', '28', '36'], // 이미지 팝오버 설정 - 리사이징 지원 popover: { image: [ ['resize', ['resizeFull', 'resizeHalf', 'resizeQuarter', 'resizeNone']], ['float', ['floatLeft', 'floatRight', 'floatNone']], ['remove', ['removeMedia']] ] }, callbacks: { onInit: function() { var $editable = $(selector).next('.note-editor').find('.note-editable'); $editable.addClass('editor-content'); $editable.on('keydown', function(e) { if (e.keyCode === 8) { e.stopPropagation(); } }); // 이미지 스타일 변경 감지 - text-align을 올바른 CSS로 변환 var observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { if (mutation.type === 'attributes' && mutation.attributeName === 'style') { var $img = $(mutation.target); if ($img.is('img')) { var style = $img.attr('style') || ''; // text-align: center -> display: block; margin: auto if (style.indexOf('text-align: center') !== -1 || style.indexOf('text-align:center') !== -1) { $img.css({ 'text-align': '', 'display': 'block', 'margin-left': 'auto', 'margin-right': 'auto', 'float': 'none' }); } // text-align: left -> float: left else if (style.indexOf('text-align: left') !== -1 || style.indexOf('text-align:left') !== -1) { $img.css({ 'text-align': '', 'display': '', 'margin-left': '', 'margin-right': '', 'float': 'left' }); } // text-align: right -> float: right else if (style.indexOf('text-align: right') !== -1 || style.indexOf('text-align:right') !== -1) { $img.css({ 'text-align': '', 'display': '', 'margin-left': '', 'margin-right': '', 'float': 'right' }); } } } }); }); observer.observe($editable[0], { attributes: true, subtree: true, attributeFilter: ['style'] }); // 커스텀 onInit 콜백 호출 if (customOptions.callbacks && customOptions.callbacks.onInit) { customOptions.callbacks.onInit.call(this); } }, // 이미지 업로드 처리 onImageUpload: function(files) { for (var i = 0; i < files.length; i++) { if (useFileUpload) { // 서버 업로드 방식 uploadImageToServer(files[i], $(selector), customOptions); } else { // data-uri 방식 (기본) insertImageAsDataUri(files[i], $(selector)); } } }, // 붙여넣기 시 선택 영역 삭제 처리 onPaste: function(e) { var clipboardData = e.originalEvent.clipboardData; if (!clipboardData) return; // 현재 선택 영역이 있으면 삭제 (전체 선택 후 붙여넣기 대응) var selection = window.getSelection(); if (selection && !selection.isCollapsed) { // 선택 영역 삭제 selection.deleteFromDocument(); } // 이미지 파일이 있는지 확인 var items = clipboardData.items; if (items) { for (var i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { var imageFile = items[i].getAsFile(); if (imageFile) { e.preventDefault(); e.stopPropagation(); if (useFileUpload) { uploadImageToServer(imageFile, $(selector), customOptions); } else { insertImageAsDataUri(imageFile, $(selector)); } return; } } } } // 이미지가 아닌 경우 summernote 기본 동작 }, // 다이얼로그 열릴 때 - 이미지 다이얼로그 안내 문구 추가 onDialogShown: function() { // 방법1: .note-modal.open.note-image-dialog (클래스가 같은 요소에 있는 경우) var $imageModal = $('.note-modal.open.note-image-dialog'); if ($imageModal.length) { addImageUploadGuide($imageModal); return; } // 방법2: .note-modal.open 안에 .note-image-dialog가 있는 경우 var $openModal = $('.note-modal.open'); if ($openModal.find('.note-image-dialog').length || $openModal.find('[class*="image"]').length) { addImageUploadGuide($openModal); } }, // 이미지 삭제 시 서버 파일도 삭제 (컨텍스트 메뉴 "사진 삭제" 클릭 시) onMediaDelete: function(target) { if (useFileUpload) { var $img = $(target); var fileId = $img.attr('data-file-id'); if (fileId) { // 서버에 파일 삭제 요청 deleteImageFromServer(fileId, customOptions); } } } } }; // 커스텀 옵션 병합 (callbacks는 위에서 별도 처리) var mergedOptions = $.extend(true, {}, defaultOptions, customOptions); // callbacks는 기본 콜백을 유지하면서 커스텀 콜백 추가 mergedOptions.callbacks = defaultOptions.callbacks; if (customOptions.callbacks) { // onInit은 위에서 이미 처리됨 // 다른 커스텀 콜백 추가 for (var key in customOptions.callbacks) { if (key !== 'onInit' && key !== 'onImageUpload' && key !== 'onPaste') { mergedOptions.callbacks[key] = customOptions.callbacks[key]; } } } $(selector).summernote(mergedOptions); } /** * 이미지를 Base64 data-uri로 변환하여 에디터에 삽입 * @param {File} file - 이미지 파일 * @param {jQuery} $editor - summernote 에디터 jQuery 객체 */ function insertImageAsDataUri(file, $editor) { // 파일 크기 제한 (10MB) var maxSize = 10 * 1024 * 1024; if (file.size > maxSize) { alert('이미지 크기는 10MB를 초과할 수 없습니다.'); return; } // 이미지 타입 확인 if (!file.type.match(/image\/(jpeg|jpg|png|gif|bmp|webp)/i)) { alert('지원하지 않는 이미지 형식입니다. (jpeg, png, gif, bmp, webp만 지원)'); return; } var reader = new FileReader(); reader.onload = function(e) { var dataUri = e.target.result; $editor.summernote('insertImage', dataUri, function($image) { // 이미지 스타일 설정 $image.css({ 'max-width': '100%', 'height': 'auto' }); $image.attr('data-filename', file.name); }); }; reader.onerror = function() { alert('이미지 읽기에 실패했습니다.'); }; reader.readAsDataURL(file); } /** * 붙여넣기된 HTML 정리 (MS Office 등에서 복사한 내용) * - 불필요한 Office 속성 제거 * - 이미지 태그 정리 (alt, v:shapes 등 제거) * @param {string} html - 원본 HTML * @returns {string} 정리된 HTML */ function cleanPastedHtml(html) { // 임시 div에서 HTML 파싱 var $temp = $('
').html(html); // 이미지 태그 정리 $temp.find('img').each(function() { var $img = $(this); var src = $img.attr('src'); // src가 없거나 file:// 프로토콜이면 제거 if (!src || src.indexOf('file://') === 0) { $img.remove(); return; } // 불필요한 속성 제거 (Office 관련) var attributesToRemove = ['alt', 'v:shapes', 'o:title', 'style', 'class', 'id', 'name']; attributesToRemove.forEach(function(attr) { $img.removeAttr(attr); }); // 깨끗한 스타일만 적용 $img.css({ 'max-width': '100%', 'height': 'auto' }); }); // VML 태그 제거 (v:, o:, w: 등) var cleanedHtml = $temp.html(); cleanedHtml = cleanedHtml.replace(/]*>[\s\S]*?<\/v:[^>]*>/gi, ''); cleanedHtml = cleanedHtml.replace(/]*>[\s\S]*?<\/o:[^>]*>/gi, ''); cleanedHtml = cleanedHtml.replace(/]*>[\s\S]*?<\/w:[^>]*>/gi, ''); cleanedHtml = cleanedHtml.replace(/]*>[\s\S]*?/gi, ''); // Office 네임스페이스 속성 제거 cleanedHtml = cleanedHtml.replace(/\s*v:[a-z]+="[^"]*"/gi, ''); cleanedHtml = cleanedHtml.replace(/\s*o:[a-z]+="[^"]*"/gi, ''); return cleanedHtml; } /** * 이미지를 서버에 업로드하고 플레이스홀더로 에디터에 삽입 * @param {File} file - 이미지 파일 * @param {jQuery} $editor - summernote 에디터 jQuery 객체 * @param {object} options - 업로드 옵션 (uploadUrl, imageViewUrl 필수) */ function uploadImageToServer(file, $editor, options) { // 파일 크기 제한 (10MB) var maxSize = 10 * 1024 * 1024; if (file.size > maxSize) { alert('이미지 크기는 10MB를 초과할 수 없습니다.'); return; } // 이미지 타입 확인 if (!file.type.match(/image\/(jpeg|jpg|png|gif|bmp|webp)/i)) { alert('지원하지 않는 이미지 형식입니다. (jpeg, png, gif, bmp, webp만 지원)'); return; } var formData = new FormData(); formData.append('file', file); formData.append('cmd', 'UPLOAD'); // 필터 인증 통과용 // 업로드 중 표시 var $editable = $editor.next('.note-editor').find('.note-editable'); var $loading = $('
이미지 업로드 중...
'); $editable.append($loading); $.ajax({ url: options.uploadUrl, method: 'POST', data: formData, processData: false, contentType: false, xhrFields: { withCredentials: true }, success: function(response) { $loading.remove(); if (response.success) { // 이미지 URL 생성 (쿼리 파라미터 방식) var imageUrl = options.imageViewUrl + '?fileId=' + response.fileId + '&fileSn=' + response.fileSn; // 이미지 삽입 $editor.summernote('insertImage', imageUrl, function($image) { // 플레이스홀더 정보를 data 속성에 저장 $image.attr('data-file-id', response.fileId); $image.attr('data-file-sn', response.fileSn); $image.attr('data-placeholder', response.placeholder); $image.css({ 'max-width': '100%', 'height': 'auto' }); }); } else { alert('이미지 업로드 실패: ' + (response.error || '알 수 없는 오류')); } }, error: function(xhr, status, error) { $loading.remove(); console.error('이미지 업로드 오류:', error); alert('이미지 업로드 중 오류가 발생했습니다.'); } }); } /** * 에디터 내용에서 이미지 태그를 플레이스홀더로 변환 (저장 시 호출) * @param {string} contents - HTML 내용 * @returns {string} 플레이스홀더로 변환된 내용 */ function convertImagesToPlaceholders(contents) { if (!contents) return contents; var $temp = $('
').html(contents); $temp.find('img[data-file-id]').each(function() { var $img = $(this); var fileId = $img.attr('data-file-id'); var fileSn = $img.attr('data-file-sn') || '1'; // 이미지 속성 수집 var attrs = []; var width = $img.attr('width'); var height = $img.attr('height'); var style = $img.attr('style'); // width/height가 유효한 값일 때만 저장 (0, auto, 빈값 제외) if (width && width !== 'auto' && width !== '0' && parseInt(width) > 0) { attrs.push('width=' + String(width).replace('px', '')); } if (height && height !== 'auto' && height !== '0' && parseInt(height) > 0) { attrs.push('height=' + String(height).replace('px', '')); } if (style && style.indexOf('width') === -1) { // style에 width가 없는 경우만 저장 (중복 방지) attrs.push('style=' + style); } // 플레이스홀더 생성 var placeholder = '{{IMG:' + fileId + ':' + fileSn; if (attrs.length > 0) { placeholder += '|' + attrs.join('|'); } placeholder += '}}'; // 이미지 태그를 플레이스홀더로 교체 $img.replaceWith(placeholder); }); return $temp.html(); } /** * 플레이스홀더를 이미지 태그로 변환 (불러올 때 호출) * @param {string} contents - 플레이스홀더가 포함된 내용 * @param {string} baseImageUrl - 이미지 서빙 베이스 URL (예: /onl/apim/editor/image/view/) * @returns {string} 이미지 태그로 변환된 HTML */ function convertPlaceholdersToImages(contents, baseImageUrl) { if (!contents) return contents; // 플레이스홀더 패턴: {{IMG:fileId:fileSn}} 또는 {{IMG:fileId:fileSn|속성}} var pattern = /\{\{IMG:([a-f0-9\-]+):(\d+)(\|[^}]+)?\}\}/gi; return contents.replace(pattern, function(match, fileId, fileSn, attrsStr) { var imgTag = '= 2) { var key = kv[0]; var value = kv.slice(1).join('='); // = 가 포함된 값 처리 imgTag += ' ' + key + '="' + value + '"'; if (key === 'style') hasStyle = true; } } } // 기본 스타일 추가 if (!hasStyle) { imgTag += ' style="max-width:100%;height:auto;"'; } imgTag += ' />'; return imgTag; }); } /** * 서버에서 이미지 파일 삭제 * @param {string} fileId - 삭제할 파일 ID * @param {object} options - 옵션 (uploadUrl에서 delete URL 유추) */ function deleteImageFromServer(fileId, options) { if (!fileId || !options.uploadUrl) return; // uploadUrl에서 delete URL 유추: /upload.json -> /delete.json var deleteUrl = options.uploadUrl.replace('/upload.json', '/delete.json'); $.ajax({ url: deleteUrl, method: 'POST', data: { cmd: 'DELETE', fileId: fileId }, success: function(response) { if (response.success) { console.log('이미지 삭제 완료: ' + fileId); } else { console.warn('이미지 삭제 실패: ' + (response.error || '')); } }, error: function(xhr, status, error) { console.error('이미지 삭제 오류:', error); } }); } /** * 내용에서 파일 ID 목록 추출 (cleanup용) * @param {string} contents - HTML 또는 플레이스홀더가 포함된 내용 * @returns {Array} 파일 ID 배열 */ function extractFileIdsFromContent(contents) { if (!contents) return []; var fileIds = []; var seen = {}; // 플레이스홀더에서 추출 var placeholderPattern = /\{\{IMG:([a-f0-9\-]+):\d+(\|[^}]+)?\}\}/gi; var match; while ((match = placeholderPattern.exec(contents)) !== null) { var fileId = match[1]; if (!seen[fileId]) { seen[fileId] = true; fileIds.push(fileId); } } // img 태그의 data-file-id에서 추출 var $temp = $('
').html(contents); $temp.find('img[data-file-id]').each(function() { var fileId = $(this).attr('data-file-id'); if (fileId && !seen[fileId]) { seen[fileId] = true; fileIds.push(fileId); } }); return fileIds; }