diff --git a/WebContent/addon/summernote/summernote-custom.css b/WebContent/addon/summernote/summernote-custom.css new file mode 100644 index 0000000..b84bb22 --- /dev/null +++ b/WebContent/addon/summernote/summernote-custom.css @@ -0,0 +1,187 @@ +/** + * Summernote Custom Styles + * - 이미지 리사이징, 레이아웃, 다이얼로그 스타일 등 + */ + +/* 에디터 이미지 스타일 */ +.note-editable img { + max-width: 100%; + cursor: pointer; +} +.note-editable img.note-image-resizing { + outline: 2px solid #007bff; +} + +/* 이미지 리사이즈 핸들 스타일 */ +.note-control-sizing { + display: block !important; +} +.note-control-holder { + position: relative; +} +.note-control-holder .note-control-sizing { + position: absolute; + width: 10px; + height: 10px; + border: 1px solid #333; + background-color: #fff; +} + +/* 코드뷰 모드 가로 사이즈 고정 */ +.note-editor.note-frame { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + box-sizing: border-box !important; + overflow: hidden !important; + display: block !important; +} +.note-editor .note-editing-area { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + box-sizing: border-box !important; + overflow: hidden !important; +} +.note-editor .note-codable { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + box-sizing: border-box !important; + resize: vertical !important; + white-space: pre-wrap !important; + word-wrap: break-word !important; + word-break: break-all !important; + overflow-x: hidden !important; + overflow-y: auto !important; + overflow-wrap: break-word !important; +} + +/* Summernote 부모 컨테이너 오버플로우 방지 */ +.table_row td .note-editor, +td .note-editor, +div .note-editor { + width: 100% !important; + max-width: 100% !important; +} + +/* 테이블 레이아웃 고정 - Summernote 포함 테이블 */ +.table_row { + table-layout: fixed !important; + width: 100% !important; +} + +/* Summernote가 있는 td 스타일 */ +td:has(.note-editor) { + overflow: hidden !important; + word-break: break-all !important; +} + +/* IE/구형 브라우저 대응 - summernote 래퍼 클래스 */ +.summernote-wrapper { + width: 100% !important; + max-width: 100% !important; + overflow: hidden !important; +} + +/* 이미지 다이얼로그 버튼 overflow 수정 */ +.note-image-dialog .note-modal-footer { + padding: 10px 15px !important; + overflow: visible !important; +} +.note-image-dialog .note-modal-content { + overflow: visible !important; +} +.note-image-dialog .note-btn { + margin: 0 !important; +} + +/* 이미지 업로드 안내 문구 스타일 */ +.image-upload-guide { + margin: 0 0 15px 0; + padding: 10px 12px; + background: #e8f4fd; + border: 1px solid #b8daff; + border-radius: 4px; + font-size: 12px; + color: #004085; + display: flex; + align-items: center; +} +.image-upload-guide i.material-icons { + font-size: 16px; + margin-right: 6px; + color: #0056b3; +} +.image-upload-guide strong { + color: #0056b3; +} + +/* 색상 팔레트 UI overflow 수정 */ +.note-editor .note-toolbar { + overflow: visible !important; +} +.note-editor .note-btn-group { + overflow: visible !important; +} +.note-editor .note-toolbar .note-color.open .note-dropdown-menu { + min-width: 340px !important; + overflow: visible !important; + display: flex !important; + flex-wrap: nowrap !important; +} +.note-editor .note-holder { + overflow: visible !important; +} + +/* 색상 팔레트 2컬럼 레이아웃 유지 */ +.note-editor .note-toolbar .note-color .note-dropdown-menu .note-palette { + display: inline-block !important; + vertical-align: top !important; + width: auto !important; + min-width: 160px !important; + margin: 0 5px !important; + flex-shrink: 0 !important; +} +.note-editor .note-toolbar .note-color .note-dropdown-menu .note-palette .note-palette-title { + margin: 0 0 5px 0 !important; + font-size: 12px !important; + font-weight: bold !important; +} +.note-editor .note-toolbar .note-color .note-dropdown-menu .note-palette .note-color-palette { + width: auto !important; +} + +/* 드롭다운 메뉴 위치 조정 */ +.note-editor .note-dropdown-menu { + z-index: 1050 !important; +} + +/* 전체화면 모드 배경색 */ +.note-editor.note-frame.fullscreen, +.note-editor.note-airframe.fullscreen { + background-color: #fff !important; +} +.note-editor.note-frame.fullscreen .note-editable, +.note-editor.note-airframe.fullscreen .note-editable { + background-color: #fff !important; +} + +/* 이미지 다이얼로그 수정 */ +.note-modal .note-modal-content { + overflow: hidden !important; +} +.note-modal .note-modal-footer { + overflow: hidden !important; + height: auto !important; + padding: 10px 15px !important; +} +.note-modal .note-modal-footer .note-btn { + float: right !important; + margin: 0 !important; +} +.note-modal .note-modal-footer::after { + content: "" !important; + display: table !important; + clear: both !important; +} diff --git a/WebContent/addon/summernote/summernote-custom.js b/WebContent/addon/summernote/summernote-custom.js new file mode 100644 index 0000000..dd3ab5c --- /dev/null +++ b/WebContent/addon/summernote/summernote-custom.js @@ -0,0 +1,530 @@ +/** + * 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; +} diff --git a/WebContent/css/editor-content.css b/WebContent/css/editor-content.css index c144110..67817f9 100644 --- a/WebContent/css/editor-content.css +++ b/WebContent/css/editor-content.css @@ -200,8 +200,9 @@ max-width: 100% !important; height: auto !important; border-radius: 8px !important; - margin: 16px 0 !important; - display: block !important; + margin-top: 16px !important; + margin-bottom: 16px !important; + /* margin-left, margin-right는 인라인 스타일로 정렬 제어 */ } /* ========================================================================== @@ -332,3 +333,51 @@ padding: 8px !important; } } + +/* ========================================================================== + Summernote 이미지 업로드 UI + ========================================================================== */ + +/* 이미지 업로드 로딩 인디케이터 */ +.image-upload-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 73, 180, 0.9); + color: #fff; + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* 이미지 다이얼로그 업로드 안내 */ +.image-upload-guide { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: #EFF6FF; + border: 1px solid #BFDBFE; + border-radius: 8px; + margin-bottom: 16px; + font-size: 13px; + color: #1E40AF; +} + +.image-upload-guide .material-icons { + font-size: 18px; + color: #3B82F6; +} + +.image-upload-guide strong { + font-weight: 600; +} diff --git a/WebContent/jsp/common/include/script.jsp b/WebContent/jsp/common/include/script.jsp index d96ba34..1e96177 100644 --- a/WebContent/jsp/common/include/script.jsp +++ b/WebContent/jsp/common/include/script.jsp @@ -56,251 +56,15 @@ + - - - + +"/>