From dda45748acc6ae33eb28c9ad30763fc4d874dd01 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Wed, 7 Jan 2026 15:31:17 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0,=20=EC=95=BD=EA=B4=80=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=97=90=EC=84=9C=20=EC=95=BD=EA=B4=80=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=ED=8C=8C=EC=9D=BC=20=EC=B2=A8=EB=B6=80=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80(DDL=20=EC=98=81?= =?UTF-8?q?=ED=96=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../addon/summernote/summernote-custom.js | 363 ++++++++++++++++-- WebContent/css/editor-content.css | 53 ++- .../apim/portalterms/portalTermsManDetail.jsp | 135 ++++++- .../interceptor/AuthorizeInterceptor.java | 9 + .../portalterms/EditorImageController.java | 155 ++++++++ .../onl/apim/portalterms/EditorImageDTO.java | 46 +++ .../apim/portalterms/EditorImageService.java | 231 +++++++++++ .../portalterms/PortalTermsManController.java | 20 +- .../portalterms/PortalTermsManService.java | 86 ++++- .../onl/apim/portalterms/PortalTermsUI.java | 9 + 10 files changed, 1045 insertions(+), 62 deletions(-) create mode 100644 src/main/java/com/eactive/eai/rms/onl/apim/portalterms/EditorImageController.java create mode 100644 src/main/java/com/eactive/eai/rms/onl/apim/portalterms/EditorImageDTO.java create mode 100644 src/main/java/com/eactive/eai/rms/onl/apim/portalterms/EditorImageService.java diff --git a/WebContent/addon/summernote/summernote-custom.js b/WebContent/addon/summernote/summernote-custom.js index 5682528..dd3ab5c 100644 --- a/WebContent/addon/summernote/summernote-custom.js +++ b/WebContent/addon/summernote/summernote-custom.js @@ -32,13 +32,18 @@ function addImageUploadGuide($modal) { /** * Summernote 에디터 공통 초기화 함수 - * - 이미지 붙여넣기 시 data-uri(Base64) 방식으로 처리 + * - 기본: data-uri(Base64) 방식으로 이미지 처리 + * - useFileUpload: true 옵션 시 서버 업로드 방식 사용 * - 이미지 리사이징 지원 * @param {string} selector - 에디터 selector (예: '#contents') - * @param {object} customOptions - 추가 옵션 (선택사항) + * @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 = { @@ -73,57 +78,102 @@ function initSummernote(selector, customOptions) { } }); + // 이미지 스타일 변경 감지 - 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); } }, - // 이미지 업로드 처리 - data-uri(Base64) 방식 + // 이미지 업로드 처리 onImageUpload: function(files) { for (var i = 0; i < files.length; i++) { - insertImageAsDataUri(files[i], $(selector)); - } - }, - // 붙여넣기 시 이미지 처리 (MS Office 등에서 복사한 이미지 포함) - onPaste: function(e) { - var clipboardData = e.originalEvent.clipboardData; - if (!clipboardData || !clipboardData.items) return; - - var items = clipboardData.items; - var imageFile = null; - - // 1. 먼저 순수 이미지 파일이 있는지 확인 - for (var i = 0; i < items.length; i++) { - if (items[i].type.indexOf('image') !== -1) { - imageFile = items[i].getAsFile(); - break; + 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; - // 2. 이미지 파일이 있으면 data-uri로 변환하여 삽입 - if (imageFile) { - e.preventDefault(); - e.stopPropagation(); - insertImageAsDataUri(imageFile, $(selector)); - return; + // 현재 선택 영역이 있으면 삭제 (전체 선택 후 붙여넣기 대응) + var selection = window.getSelection(); + if (selection && !selection.isCollapsed) { + // 선택 영역 삭제 + selection.deleteFromDocument(); } - // 3. HTML 붙여넣기인 경우 (MS Office 등) - 이미지 태그 정리 - var htmlData = clipboardData.getData('text/html'); - if (htmlData && htmlData.indexOf(' 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/onl/apim/portalterms/portalTermsManDetail.jsp b/WebContent/jsp/onl/apim/portalterms/portalTermsManDetail.jsp index f5f7a0c..8892a90 100644 --- a/WebContent/jsp/onl/apim/portalterms/portalTermsManDetail.jsp +++ b/WebContent/jsp/onl/apim/portalterms/portalTermsManDetail.jsp @@ -19,12 +19,45 @@ "/> +