summernote 일부 개선
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* Summernote Custom JavaScript
|
||||||
|
* - 에디터 초기화, 이미지 처리, 붙여넣기 처리 등
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 다이얼로그에 안내 문구 추가
|
||||||
|
* @param {jQuery} $modal - 모달 jQuery 객체
|
||||||
|
*/
|
||||||
|
function addImageUploadGuide($modal) {
|
||||||
|
// 이미 안내 문구가 있으면 무시
|
||||||
|
if ($modal.find('.image-upload-guide').length) return;
|
||||||
|
|
||||||
|
var guideHtml =
|
||||||
|
'<div class="image-upload-guide">' +
|
||||||
|
'<i class="material-icons">info</i>' +
|
||||||
|
'<span>지원 형식: JPG, PNG, GIF, BMP, WEBP / 최대 크기: <strong>10MB</strong></span>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// 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) 방식으로 처리
|
||||||
|
* - 이미지 리사이징 지원
|
||||||
|
* @param {string} selector - 에디터 selector (예: '#contents')
|
||||||
|
* @param {object} customOptions - 추가 옵션 (선택사항)
|
||||||
|
*/
|
||||||
|
function initSummernote(selector, customOptions) {
|
||||||
|
customOptions = customOptions || {};
|
||||||
|
|
||||||
|
// 기본 설정
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 커스텀 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 이미지 파일이 있으면 data-uri로 변환하여 삽입
|
||||||
|
if (imageFile) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
insertImageAsDataUri(imageFile, $(selector));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. HTML 붙여넣기인 경우 (MS Office 등) - 이미지 태그 정리
|
||||||
|
var htmlData = clipboardData.getData('text/html');
|
||||||
|
if (htmlData && htmlData.indexOf('<img') !== -1) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// HTML에서 이미지 src 추출 및 정리
|
||||||
|
var cleanedHtml = cleanPastedHtml(htmlData);
|
||||||
|
|
||||||
|
// 에디터에 포커스 후 HTML 삽입
|
||||||
|
$(selector).summernote('focus');
|
||||||
|
setTimeout(function() {
|
||||||
|
$(selector).summernote('pasteHTML', cleanedHtml);
|
||||||
|
}, 10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 다이얼로그 열릴 때 - 이미지 다이얼로그 안내 문구 추가
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 커스텀 옵션 병합 (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 = $('<div>').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(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
|
||||||
|
cleanedHtml = cleanedHtml.replace(/<o:[^>]*>[\s\S]*?<\/o:[^>]*>/gi, '');
|
||||||
|
cleanedHtml = cleanedHtml.replace(/<w:[^>]*>[\s\S]*?<\/w:[^>]*>/gi, '');
|
||||||
|
cleanedHtml = cleanedHtml.replace(/<!\[if[^>]*>[\s\S]*?<!\[endif\]>/gi, '');
|
||||||
|
|
||||||
|
// Office 네임스페이스 속성 제거
|
||||||
|
cleanedHtml = cleanedHtml.replace(/\s*v:[a-z]+="[^"]*"/gi, '');
|
||||||
|
cleanedHtml = cleanedHtml.replace(/\s*o:[a-z]+="[^"]*"/gi, '');
|
||||||
|
|
||||||
|
return cleanedHtml;
|
||||||
|
}
|
||||||
@@ -56,251 +56,15 @@
|
|||||||
<script src="<c:url value="/addon/codemirror/addon/lint/json-lint.js"/>"></script>
|
<script src="<c:url value="/addon/codemirror/addon/lint/json-lint.js"/>"></script>
|
||||||
<script src="<c:url value="/addon/codemirror/addon/lint/css-lint.js"/>"></script>
|
<script src="<c:url value="/addon/codemirror/addon/lint/css-lint.js"/>"></script>
|
||||||
|
|
||||||
|
<!-- Summernote -->
|
||||||
<script src="<c:url value="/addon/summernote/summernote-lite.min.js"/>"></script>
|
<script src="<c:url value="/addon/summernote/summernote-lite.min.js"/>"></script>
|
||||||
<script src="<c:url value="/addon/summernote/summernote-cleaner.js"/>"></script>
|
<script src="<c:url value="/addon/summernote/summernote-cleaner.js"/>"></script>
|
||||||
<script src="<c:url value="/addon/summernote/lang/summernote-ko-KR.js"/>"></script>
|
<script src="<c:url value="/addon/summernote/lang/summernote-ko-KR.js"/>"></script>
|
||||||
|
<script src="<c:url value="/addon/summernote/summernote-custom.js"/>"></script>
|
||||||
<!-- Summernote 스타일 -->
|
<link rel="stylesheet" type="text/css" href="<c:url value="/addon/summernote/summernote-custom.css"/>"/>
|
||||||
<style>
|
|
||||||
.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-codable {
|
|
||||||
width: 100% !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
resize: vertical !important;
|
|
||||||
white-space: pre-wrap !important;
|
|
||||||
word-wrap: break-word !important;
|
|
||||||
overflow-x: auto !important;
|
|
||||||
}
|
|
||||||
.note-editor.note-frame {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script language="javascript">
|
<script language="javascript">
|
||||||
|
|
||||||
/**
|
|
||||||
* Summernote 에디터 공통 초기화 함수
|
|
||||||
* - 이미지 붙여넣기 시 data-uri(Base64) 방식으로 처리
|
|
||||||
* - 이미지 리사이징 지원
|
|
||||||
* @param {string} selector - 에디터 selector (예: '#contents')
|
|
||||||
* @param {object} customOptions - 추가 옵션 (선택사항)
|
|
||||||
*/
|
|
||||||
function initSummernote(selector, customOptions) {
|
|
||||||
customOptions = customOptions || {};
|
|
||||||
|
|
||||||
// 기본 설정
|
|
||||||
var defaultOptions = {
|
|
||||||
placeholder: customOptions.placeholder || '여기에 내용을 입력하세요.',
|
|
||||||
height: customOptions.height || 300,
|
|
||||||
lang: 'ko-KR',
|
|
||||||
toolbar: customOptions.toolbar || [
|
|
||||||
['style', ['style']],
|
|
||||||
['font', ['bold', 'underline', 'clear']],
|
|
||||||
['color', ['color']],
|
|
||||||
['para', ['ul', 'ol', 'paragraph']],
|
|
||||||
['table', ['table']],
|
|
||||||
['insert', ['link', 'picture']],
|
|
||||||
['view', ['fullscreen', 'codeview', 'help']]
|
|
||||||
],
|
|
||||||
// 이미지 팝오버 설정 - 리사이징 지원
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 커스텀 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 이미지 파일이 있으면 data-uri로 변환하여 삽입
|
|
||||||
if (imageFile) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
insertImageAsDataUri(imageFile, $(selector));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. HTML 붙여넣기인 경우 (MS Office 등) - 이미지 태그 정리
|
|
||||||
var htmlData = clipboardData.getData('text/html');
|
|
||||||
if (htmlData && htmlData.indexOf('<img') !== -1) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// HTML에서 이미지 src 추출 및 정리
|
|
||||||
var cleanedHtml = cleanPastedHtml(htmlData);
|
|
||||||
|
|
||||||
// 에디터에 포커스 후 HTML 삽입
|
|
||||||
$(selector).summernote('focus');
|
|
||||||
setTimeout(function() {
|
|
||||||
$(selector).summernote('pasteHTML', cleanedHtml);
|
|
||||||
}, 10);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 커스텀 옵션 병합 (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) {
|
|
||||||
// 파일 크기 제한 (5MB)
|
|
||||||
var maxSize = 5 * 1024 * 1024;
|
|
||||||
if (file.size > maxSize) {
|
|
||||||
alert('이미지 크기는 5MB를 초과할 수 없습니다.');
|
|
||||||
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 = $('<div>').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(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
|
|
||||||
cleanedHtml = cleanedHtml.replace(/<o:[^>]*>[\s\S]*?<\/o:[^>]*>/gi, '');
|
|
||||||
cleanedHtml = cleanedHtml.replace(/<w:[^>]*>[\s\S]*?<\/w:[^>]*>/gi, '');
|
|
||||||
cleanedHtml = cleanedHtml.replace(/<!\[if[^>]*>[\s\S]*?<!\[endif\]>/gi, '');
|
|
||||||
|
|
||||||
// Office 네임스페이스 속성 제거
|
|
||||||
cleanedHtml = cleanedHtml.replace(/\s*v:[a-z]+="[^"]*"/gi, '');
|
|
||||||
cleanedHtml = cleanedHtml.replace(/\s*o:[a-z]+="[^"]*"/gi, '');
|
|
||||||
|
|
||||||
return cleanedHtml;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buttonControl() {
|
function buttonControl() {
|
||||||
if (arguments.length == 0) {
|
if (arguments.length == 0) {
|
||||||
$("img[level], button[level]").hide();
|
$("img[level], button[level]").hide();
|
||||||
|
|||||||
@@ -152,13 +152,15 @@
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
$("#agreementsType").prop('disabled', false);
|
$("#agreementsType").prop('disabled', false);
|
||||||
var postData = $('#ajaxForm').serializeArray();
|
|
||||||
|
|
||||||
if (isDetail) {
|
// 일반 POST로 전송 (이미지는 서버 업로드 방식으로 URL만 저장됨)
|
||||||
postData.push({ name: "cmd", value: "UPDATE" });
|
var postData = {
|
||||||
} else {
|
cmd: isDetail ? "UPDATE" : "INSERT",
|
||||||
postData.push({ name: "cmd", value: "INSERT" });
|
agreementsType: $("#agreementsType").val(),
|
||||||
}
|
revision: $("#revisionHidden").val(),
|
||||||
|
publishedOn: $("#publishedOn").val(),
|
||||||
|
contents: contents
|
||||||
|
};
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: "POST",
|
type: "POST",
|
||||||
@@ -169,7 +171,8 @@
|
|||||||
goNav(returnUrl);
|
goNav(returnUrl);
|
||||||
},
|
},
|
||||||
error: function (e) {
|
error: function (e) {
|
||||||
alert(e.responseText);
|
var errorMsg = e.responseText || e.statusText || "저장 중 오류가 발생했습니다.";
|
||||||
|
alert(errorMsg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ class RequestWrapper extends HttpServletRequestWrapper {
|
|||||||
"^data:image/.*",
|
"^data:image/.*",
|
||||||
Pattern.CASE_INSENSITIVE);
|
Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
|
// XSS 문자 변환을 건너뛸 파라미터 목록 (리치 텍스트 에디터 콘텐츠 등)
|
||||||
|
// XSS 패턴 필터링은 적용하지만 <, > 등으로 변환하지 않음
|
||||||
|
private static final String[] SKIP_CHAR_CONVERT_PARAMS = {"contents", "content", "description"};
|
||||||
|
|
||||||
|
// 현재 처리 중인 파라미터명 (cleanXSS에서 문자 변환 여부 결정에 사용)
|
||||||
|
private ThreadLocal<String> currentParameter = new ThreadLocal<>();
|
||||||
|
|
||||||
public RequestWrapper(HttpServletRequest servletRequest) {
|
public RequestWrapper(HttpServletRequest servletRequest) {
|
||||||
super(servletRequest);
|
super(servletRequest);
|
||||||
@@ -67,8 +73,13 @@ class RequestWrapper extends HttpServletRequestWrapper {
|
|||||||
int count = values.length;
|
int count = values.length;
|
||||||
String[] encodedValues = new String[count];
|
String[] encodedValues = new String[count];
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
currentParameter.set(parameter);
|
||||||
encodedValues[i] = cleanXSS(values[i]);
|
try {
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
encodedValues[i] = cleanXSS(values[i]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
currentParameter.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
return encodedValues;
|
return encodedValues;
|
||||||
@@ -81,13 +92,19 @@ class RequestWrapper extends HttpServletRequestWrapper {
|
|||||||
|
|
||||||
for (Entry<String, String[]> entry : map.entrySet()) {
|
for (Entry<String, String[]> entry : map.entrySet()) {
|
||||||
String[] values = entry.getValue();
|
String[] values = entry.getValue();
|
||||||
|
String paramName = entry.getKey();
|
||||||
|
|
||||||
if (values != null && values.length > 0) {
|
if (values != null && values.length > 0) {
|
||||||
String[] cloneValues = new String[values.length];
|
String[] cloneValues = new String[values.length];
|
||||||
for (int idx = 0; idx < values.length; idx++) {
|
currentParameter.set(paramName);
|
||||||
cloneValues[idx] = cleanXSS(values[idx]);
|
try {
|
||||||
|
for (int idx = 0; idx < values.length; idx++) {
|
||||||
|
cloneValues[idx] = cleanXSS(values[idx]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
currentParameter.remove();
|
||||||
}
|
}
|
||||||
clonedMap.put(entry.getKey(), cloneValues);
|
clonedMap.put(paramName, cloneValues);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +119,12 @@ class RequestWrapper extends HttpServletRequestWrapper {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanXSS(value);
|
currentParameter.set(parameter);
|
||||||
|
try {
|
||||||
|
return cleanXSS(value);
|
||||||
|
} finally {
|
||||||
|
currentParameter.remove();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -134,9 +156,11 @@ class RequestWrapper extends HttpServletRequestWrapper {
|
|||||||
// src 속성 화이트리스트 필터링 - 허용된 프로토콜만 통과
|
// src 속성 화이트리스트 필터링 - 허용된 프로토콜만 통과
|
||||||
value = filterSrcAttribute(value);
|
value = filterSrcAttribute(value);
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
// 리치 텍스트 파라미터는 문자 변환을 건너뛰고 XSS 패턴 필터링만 적용
|
||||||
|
if (!shouldSkipCharConvert()) {
|
||||||
value = convertChars(value, sb);
|
StringBuilder sb = new StringBuilder();
|
||||||
|
value = convertChars(value, sb);
|
||||||
|
}
|
||||||
|
|
||||||
// eval, javascript 등 스크립트 관련 문자열 제거
|
// eval, javascript 등 스크립트 관련 문자열 제거
|
||||||
value = value.replaceAll("(?i)eval\\((.*?)\\)", "");
|
value = value.replaceAll("(?i)eval\\((.*?)\\)", "");
|
||||||
@@ -144,6 +168,23 @@ class RequestWrapper extends HttpServletRequestWrapper {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 파라미터가 문자 변환을 건너뛸지 여부 확인
|
||||||
|
* 리치 텍스트 에디터 콘텐츠 등은 HTML 태그가 필요하므로 < > 변환을 하지 않음
|
||||||
|
*/
|
||||||
|
private boolean shouldSkipCharConvert() {
|
||||||
|
String param = currentParameter.get();
|
||||||
|
if (param == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String skipParam : SKIP_CHAR_CONVERT_PARAMS) {
|
||||||
|
if (StringUtils.equalsIgnoreCase(param, skipParam)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* src 속성을 화이트리스트 방식으로 필터링
|
* src 속성을 화이트리스트 방식으로 필터링
|
||||||
* 허용: data:image/ (Base64 이미지)
|
* 허용: data:image/ (Base64 이미지)
|
||||||
|
|||||||
Reference in New Issue
Block a user