리빌더 부분 추가
This commit is contained in:
221
plugin/editor/rb.editor/js/ai.js
Normal file
221
plugin/editor/rb.editor/js/ai.js
Normal file
@ -0,0 +1,221 @@
|
||||
function b64toBlob(b64Data, contentType, sliceSize) {
|
||||
contentType = contentType || '';
|
||||
sliceSize = sliceSize || 512;
|
||||
var byteCharacters = atob(b64Data);
|
||||
var byteArrays = [];
|
||||
for (var offset = 0; offset < byteCharacters.length; offset += sliceSize) {
|
||||
var slice = byteCharacters.slice(offset, offset + sliceSize);
|
||||
var byteNumbers = new Array(slice.length);
|
||||
for (var i = 0; i < slice.length; i++) {
|
||||
byteNumbers[i] = slice.charCodeAt(i);
|
||||
}
|
||||
var byteArray = new Uint8Array(byteNumbers);
|
||||
byteArrays.push(byteArray);
|
||||
}
|
||||
return new Blob(byteArrays, {type: contentType});
|
||||
}
|
||||
|
||||
|
||||
function uploadImageAI(blob) {
|
||||
var formData = new FormData();
|
||||
// 파일명은 임의로 지정 ("generated.png")
|
||||
formData.append("file", blob, "generated.png");
|
||||
|
||||
// 기존에 숨겨진 input(#editor_nonce)이 있다면 사용하고,
|
||||
// 없으면 전역 변수 ed_nonce를 사용합니다.
|
||||
var nonceElem = document.getElementById('editor_nonce');
|
||||
if (nonceElem && nonceElem.value) {
|
||||
formData.append("editor_nonce", nonceElem.value);
|
||||
} else if (ed_nonce) {
|
||||
formData.append("editor_nonce", ed_nonce);
|
||||
} else {
|
||||
//console.warn("Nonce 값이 설정되어 있지 않습니다.");
|
||||
}
|
||||
|
||||
return fetch(g5Config.g5_editor_url + '/php/rb.upload.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(function(response) {
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
// 함수: 작업 유형에 따른 placeholder 변경
|
||||
function updatePlaceholder() {
|
||||
var taskType = document.querySelector('input[name="taskType"]:checked').value;
|
||||
var promptInput = document.getElementById('prompt');
|
||||
if (taskType === 'image') {
|
||||
document.getElementById("prompt_ai_img").src = "./image/ai/Stability.svg";
|
||||
promptInput.placeholder = "생성 하고자하는 주제를 영문(단어)로 입력하세요.";
|
||||
} else {
|
||||
document.getElementById("prompt_ai_img").src = "./image/ai/Gemini.svg";
|
||||
promptInput.placeholder = "이전 대화를 기억하지 못합니다. 질의나 문장 생성용으로 사용하세요.";
|
||||
}
|
||||
}
|
||||
|
||||
// 엔터키 입력 시 바로 요청
|
||||
document.getElementById('prompt').addEventListener('keydown', function (e) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
document.getElementById('generateBtn').click();
|
||||
}
|
||||
});
|
||||
|
||||
// 라디오 버튼 변경 시 플레이스홀더 업데이트
|
||||
var radios = document.querySelectorAll('input[name="taskType"]');
|
||||
radios.forEach(function (radio) {
|
||||
radio.addEventListener('change', updatePlaceholder);
|
||||
});
|
||||
updatePlaceholder(); // 초기 로드시 설정
|
||||
|
||||
document.getElementById('generateBtn').addEventListener('click', function () {
|
||||
var prompt = document.getElementById('prompt').value.trim();
|
||||
var taskType = document.querySelector('input[name="taskType"]:checked').value; // 선택된 작업 유형
|
||||
|
||||
if (prompt === '') {
|
||||
alert('생성하실 주제를 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
var btn = document.getElementById('generateBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '생각 중..';
|
||||
document.getElementById('result').innerHTML = '';
|
||||
|
||||
// 로딩 오버레이 표시
|
||||
var overlay = document.querySelector(".loadingOverlay_ai");
|
||||
overlay.style.display = "block";
|
||||
|
||||
// API 요청 데이터 설정
|
||||
var formData = new URLSearchParams();
|
||||
formData.append("prompt", prompt);
|
||||
formData.append("taskType", taskType);
|
||||
|
||||
fetch(g5Config.g5_editor_url + '/plugin/ai/ajax.generate.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: formData.toString()
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
|
||||
var editor = document.getElementById("editor");
|
||||
var accumulateCheckbox = document.getElementById('accumulateCheckbox');
|
||||
|
||||
if (taskType === 'text' && data.text) {
|
||||
|
||||
document.getElementById("prompt_ai_img").src = "./image/ai/Gemini.svg";
|
||||
|
||||
// 텍스트의 경우 누적 체크박스가 체크되지 않으면 에디터 초기화
|
||||
if (!(accumulateCheckbox && accumulateCheckbox.checked)) {
|
||||
editor.innerHTML = "";
|
||||
}
|
||||
var newText = document.createElement("div");
|
||||
newText.innerHTML = data.text;
|
||||
|
||||
var emptyDiv = document.createElement("div");
|
||||
emptyDiv.innerHTML = "<br>";
|
||||
|
||||
if (editor) {
|
||||
editor.appendChild(newText);
|
||||
editor.appendChild(emptyDiv); // 빈 줄 추가
|
||||
} else {
|
||||
//console.error("Error: #editor element not found.");
|
||||
}
|
||||
//document.getElementById('result').innerHTML = '<p style="font-size: 12px; color:#999; text-align:right">모델 : ' + data.model + '</p>';
|
||||
// 텍스트 생성 후 입력창 비우기
|
||||
document.getElementById('prompt').value = "";
|
||||
|
||||
|
||||
} else if (taskType === 'image') {
|
||||
|
||||
document.getElementById("prompt_ai_img").src = "./image/ai/Stability.svg";
|
||||
|
||||
if (data.image && data.image.length > 0) {
|
||||
// 이미지 생성 성공 시
|
||||
if (!(accumulateCheckbox && accumulateCheckbox.checked)) {
|
||||
editor.innerHTML = "";
|
||||
}
|
||||
|
||||
// base64 데이터를 Blob으로 변환 (타입: image/png)
|
||||
var blob = b64toBlob(data.image, "image/png");
|
||||
|
||||
// 생성된 이미지를 업로드합니다.
|
||||
uploadImageAI(blob).then(function(uploadResponse) {
|
||||
if (uploadResponse.files && uploadResponse.files[0] && uploadResponse.files[0].url) {
|
||||
var fileUrl = uploadResponse.files[0].url;
|
||||
|
||||
// ✅ .resizable_wrap 부모 div 생성
|
||||
var resizableWrap = document.createElement("div");
|
||||
resizableWrap.classList.add("resizable_wrap");
|
||||
resizableWrap.style.position = "relative";
|
||||
//resizableWrap.style.display = "inline-block"; // 부모 크기 유지
|
||||
|
||||
// ✅ AI 이미지용 wrapper 생성 (크기 및 리사이즈 핸들 포함)
|
||||
var newImage = document.createElement("div");
|
||||
newImage.classList.add("resizable", "rb_ai_image");
|
||||
newImage.style.width = "450px";
|
||||
newImage.style.height = "300px";
|
||||
newImage.style.position = "relative";
|
||||
|
||||
// 원본 크기와 비율을 data 속성에 추가 (원본 너비: 450, 원본 높이: 300)
|
||||
newImage.setAttribute("data-original-width", "450");
|
||||
newImage.setAttribute("data-original-height", "300");
|
||||
newImage.setAttribute("data-ratio", (300 / 450).toString());
|
||||
|
||||
newImage.innerHTML = `
|
||||
<img src="${fileUrl}" alt="Generated Image" crossorigin="anonymous" draggable="false"
|
||||
style="width: 100%; height: 100%; object-fit: cover;">
|
||||
<div class="resize-handle"></div>
|
||||
`;
|
||||
|
||||
// ✅ .resizable_wrap 내부에 .resizable 추가
|
||||
resizableWrap.appendChild(newImage);
|
||||
editor.appendChild(resizableWrap);
|
||||
|
||||
// 현재 newImage의 width에 맞춰 높이를 재계산하여 비율 유지
|
||||
function updateHeight() {
|
||||
var ratio = parseFloat(newImage.getAttribute("data-ratio"));
|
||||
if (!isNaN(ratio)) {
|
||||
newImage.style.height = (newImage.offsetWidth * ratio) + "px";
|
||||
}
|
||||
}
|
||||
// 초기 높이 업데이트
|
||||
updateHeight();
|
||||
// 창 크기 변경 시 업데이트
|
||||
window.addEventListener("resize", updateHeight);
|
||||
|
||||
// 필요시, 리사이즈 기능 활성화 (예: makeImageResizableWithObserver)
|
||||
makeImageResizableWithObserver($(newImage));
|
||||
} else {
|
||||
document.getElementById('result').innerHTML = '<p style="font-size: 12px; color:#f55036">이미지 업로드 실패</p>';
|
||||
}
|
||||
}).catch(function(err) {
|
||||
document.getElementById('result').innerHTML = '<p style="font-size: 12px; color:#f55036">이미지 업로드 오류</p>';
|
||||
});
|
||||
} else {
|
||||
// 이미지 생성 실패 또는 에러 처리...
|
||||
if (data.error) {
|
||||
document.getElementById('result').innerHTML = '<p style="font-size: 12px; color:#f55036">이미지 생성 실패</p>';
|
||||
} else {
|
||||
document.getElementById('result').innerHTML = '<p style="font-size: 12px; color:#f55036">잠시후 다시 생성해주세요.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
} else if (data.error) {
|
||||
document.getElementById('result').innerHTML = '<p style="font-size: 12px; color:#f55036">오류가 있습니다.</p>';
|
||||
}
|
||||
|
||||
overlay.style.display = "none";
|
||||
btn.disabled = false;
|
||||
btn.textContent = '생성';
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('result').innerHTML = '<p style="font-size: 12px; color:#f55036">오류가 있습니다.</p>';
|
||||
document.querySelector(".loadingOverlay_ai").style.display = "none";
|
||||
btn.disabled = false;
|
||||
btn.textContent = '생성';
|
||||
});
|
||||
});
|
||||
261
plugin/editor/rb.editor/js/canvas.js
Normal file
261
plugin/editor/rb.editor/js/canvas.js
Normal file
@ -0,0 +1,261 @@
|
||||
//===============================================================================
|
||||
// #region Fabric.js 캔버스 텍스트 및 이미지 삽입 관련 이벤트 및 함수
|
||||
//===============================================================================
|
||||
|
||||
/** 이미지 업로드 변경 이벤트: 선택한 이미지를 Fabric 캔버스에 추가 */
|
||||
$('#canvas-image-upload').change(function (event) {
|
||||
const files = event.target.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
fabric.Image.fromURL(e.target.result, function (img) {
|
||||
// 이미지 중앙 배치 및 캔버스에 추가
|
||||
img.set({
|
||||
left: fabricCanvas.width / 2 - img.width / 2,
|
||||
top: fabricCanvas.height / 2 - img.height / 2,
|
||||
selectable: true
|
||||
});
|
||||
fabricCanvas.add(img);
|
||||
fabricCanvas.renderAll();
|
||||
});
|
||||
}
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
alert('이미지 파일을 선택해주세요.');
|
||||
}
|
||||
}
|
||||
// 파일 입력 초기화
|
||||
$('#canvas-image-upload').val('');
|
||||
});
|
||||
|
||||
/** 텍스트 삽입 버튼 클릭 이벤트: 사용자 입력 텍스트를 Fabric 캔버스에 IText 객체로 추가 */
|
||||
$('#canvas-insert-text-btn').click(function () {
|
||||
const text = prompt("추가할 텍스트를 입력하세요:", "새 텍스트");
|
||||
if (text) {
|
||||
const itext = new fabric.IText(text, {
|
||||
left: fabricCanvas.width / 2,
|
||||
top: fabricCanvas.height / 2,
|
||||
fontSize: 20,
|
||||
fill: '#000000',
|
||||
selectable: true,
|
||||
objectCaching: false
|
||||
});
|
||||
fabricCanvas.add(itext);
|
||||
fabricCanvas.setActiveObject(itext);
|
||||
}
|
||||
});
|
||||
|
||||
/** 공용 함수: 부분 선택이 있으면 부분만, 없으면 전체 텍스트에 스타일 토글 */
|
||||
function toggleCanvasTextStyle(styleName, toggleValues) {
|
||||
const activeObject = fabricCanvas.getActiveObject();
|
||||
if (!activeObject || activeObject.type !== 'i-text') return;
|
||||
|
||||
// 선택 범위 가져오기
|
||||
const start = activeObject.selectionStart;
|
||||
const end = activeObject.selectionEnd;
|
||||
|
||||
// 전체 텍스트에 적용하는 경우
|
||||
if (start === end) {
|
||||
const current = activeObject[styleName];
|
||||
// 현재 스타일값을 토글
|
||||
const newVal = (current === toggleValues[0]) ? toggleValues[1] : toggleValues[0];
|
||||
activeObject.set(styleName, newVal);
|
||||
} else {
|
||||
// 부분 선택이 있는 경우
|
||||
const currentStyles = activeObject.getSelectionStyles(start, end);
|
||||
let allSame = true;
|
||||
for (let i = 0; i < currentStyles.length; i++) {
|
||||
if (currentStyles[i][styleName] !== toggleValues[0]) {
|
||||
allSame = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 스타일 토글 값 결정
|
||||
const newVal = allSame ? toggleValues[1] : toggleValues[0];
|
||||
activeObject.setSelectionStyles({
|
||||
[styleName]: newVal
|
||||
}, start, end);
|
||||
}
|
||||
fabricCanvas.renderAll();
|
||||
}
|
||||
|
||||
/** 캔버스 굵게 버튼 클릭 이벤트 */
|
||||
$('#canvas-bold-btn').click(function () {
|
||||
// fontWeight를 'bold' <-> 'normal' 로 토글
|
||||
toggleCanvasTextStyle('fontWeight', ['bold', 'normal']);
|
||||
});
|
||||
|
||||
/** 캔버스 기울임 버튼 클릭 이벤트 */
|
||||
$('#canvas-italic-btn').click(function () {
|
||||
// fontStyle을 'italic' <-> 'normal' 로 토글
|
||||
toggleCanvasTextStyle('fontStyle', ['italic', 'normal']);
|
||||
});
|
||||
|
||||
/** 캔버스 밑줄 버튼 클릭 이벤트 */
|
||||
$('#canvas-underline-btn').click(function () {
|
||||
const activeObject = fabricCanvas.getActiveObject();
|
||||
if (!activeObject || activeObject.type !== 'i-text') return;
|
||||
|
||||
const start = activeObject.selectionStart;
|
||||
const end = activeObject.selectionEnd;
|
||||
if (start === end) {
|
||||
// 전체 텍스트 밑줄 토글
|
||||
activeObject.set('underline', !activeObject.get('underline'));
|
||||
} else {
|
||||
// 부분 선택 영역에 대해 밑줄 토글
|
||||
const currentStyles = activeObject.getSelectionStyles(start, end);
|
||||
let allUnderlined = true;
|
||||
for (let i = 0; i < currentStyles.length; i++) {
|
||||
if (!currentStyles[i].underline) {
|
||||
allUnderlined = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const newVal = !allUnderlined;
|
||||
activeObject.setSelectionStyles({
|
||||
underline: newVal
|
||||
}, start, end);
|
||||
}
|
||||
fabricCanvas.renderAll();
|
||||
});
|
||||
|
||||
/** 캔버스 텍스트 색상 변경 이벤트 */
|
||||
$('#canvas-text-color-picker').on('input', function () {
|
||||
const color = $(this).val();
|
||||
applyCanvasTextColor(color);
|
||||
});
|
||||
|
||||
/** 캔버스 글자 크기 변경 이벤트: 직접 입력된 값 적용 */
|
||||
$('#canvas-font-size-btn').change(function () {
|
||||
const fontSize = parseInt($(this).val(), 10);
|
||||
const activeObject = fabricCanvas.getActiveObject();
|
||||
if (activeObject && activeObject.type === 'i-text') {
|
||||
activeObject.set('fontSize', fontSize);
|
||||
fabricCanvas.renderAll();
|
||||
}
|
||||
});
|
||||
|
||||
/** 펜 설정 토글 버튼 클릭 이벤트 (옵션 예시) */
|
||||
$('#pen-settings-btn').click(function () {
|
||||
$('#pen-settings').toggle();
|
||||
});
|
||||
|
||||
/** 펜 색상 변경 이벤트 */
|
||||
$('#pen-color-picker').change(function () {
|
||||
if (fabricCanvas.isDrawingMode) {
|
||||
fabricCanvas.freeDrawingBrush.color = $(this).val();
|
||||
}
|
||||
});
|
||||
|
||||
/** 펜 두께 변경 이벤트 */
|
||||
$('#pen-thickness').change(function () {
|
||||
if (fabricCanvas.isDrawingMode) {
|
||||
fabricCanvas.freeDrawingBrush.width = parseInt($(this).val(), 10) || 2;
|
||||
}
|
||||
});
|
||||
|
||||
/** 드로잉 모드 토글 버튼 클릭 이벤트 */
|
||||
$('#toggle-draw-btn').click(function () {
|
||||
if (fabricCanvas) {
|
||||
isDrawingMode = !isDrawingMode;
|
||||
fabricCanvas.isDrawingMode = isDrawingMode;
|
||||
if (isDrawingMode) {
|
||||
$(this).addClass('on');
|
||||
$('#pen-settings').show();
|
||||
// 새로운 PencilBrush 설정 및 기본 속성 적용
|
||||
fabricCanvas.freeDrawingBrush = new fabric.PencilBrush(fabricCanvas);
|
||||
fabricCanvas.freeDrawingBrush.color = $('#pen-color-picker').val();
|
||||
fabricCanvas.freeDrawingBrush.width = parseInt($('#pen-thickness').val(), 10) || 2;
|
||||
fabricCanvas.freeDrawingCursor = 'url("image/svg/pen-btn.svg") 3 15, auto';
|
||||
console.log('드로잉 모드 활성');
|
||||
} else {
|
||||
$(this).removeClass('on');
|
||||
$('#pen-settings').hide();
|
||||
fabricCanvas.freeDrawingCursor = 'default';
|
||||
console.log('드로잉 비활성');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** 캔버스 이미지 삽입 버튼 클릭 이벤트: 이미지 업로드 창 트리거 */
|
||||
$('#canvas-insert-image-btn').click(function () {
|
||||
$('#canvas-image-upload').click();
|
||||
});
|
||||
|
||||
/** 캔버스 텍스트 색상 적용 함수 */
|
||||
function applyCanvasTextColor(color) {
|
||||
const obj = fabricCanvas.getActiveObject();
|
||||
if (!obj || obj.type !== 'i-text') return;
|
||||
|
||||
const s = obj.selectionStart;
|
||||
const e = obj.selectionEnd;
|
||||
|
||||
if (s < e) {
|
||||
// 부분 선택 구간만 색상 변경
|
||||
obj.setSelectionStyles({
|
||||
fill: color
|
||||
}, s, e);
|
||||
} else {
|
||||
// 전체 텍스트 색상 변경
|
||||
obj.set('fill', color);
|
||||
}
|
||||
|
||||
// 즉시 반영
|
||||
obj.dirty = true;
|
||||
obj.setCoords();
|
||||
fabricCanvas.renderAll();
|
||||
}
|
||||
|
||||
/** 캔버스 텍스트 배경색 변경 함수 */
|
||||
function applyCanvasBackgroundColor(color) {
|
||||
const obj = fabricCanvas.getActiveObject();
|
||||
if (!obj || obj.type !== 'i-text') return;
|
||||
|
||||
const s = obj.selectionStart;
|
||||
const e = obj.selectionEnd;
|
||||
|
||||
if (s < e) {
|
||||
// 부분 선택 구간만 배경색 변경
|
||||
obj.setSelectionStyles({
|
||||
backgroundColor: color
|
||||
}, s, e);
|
||||
} else {
|
||||
// 전체 텍스트 배경색 변경
|
||||
obj.set('backgroundColor', color);
|
||||
}
|
||||
|
||||
// 즉시 반영
|
||||
obj.dirty = true;
|
||||
obj.setCoords();
|
||||
fabricCanvas.renderAll();
|
||||
}
|
||||
|
||||
/** 캔버스 폰트 크기 적용 함수 */
|
||||
function applyCanvasFontSize(fontSize) {
|
||||
const obj = fabricCanvas.getActiveObject();
|
||||
if (!obj || obj.type !== 'i-text') return;
|
||||
|
||||
const s = obj.selectionStart;
|
||||
const e = obj.selectionEnd;
|
||||
|
||||
if (s < e) {
|
||||
// 부분 선택 구간만 폰트 크기 변경
|
||||
obj.setSelectionStyles({
|
||||
fontSize: fontSize
|
||||
}, s, e);
|
||||
} else {
|
||||
// 전체 텍스트 폰트 크기 변경
|
||||
obj.set('fontSize', fontSize);
|
||||
}
|
||||
|
||||
// 즉시 반영
|
||||
obj.dirty = true;
|
||||
obj.setCoords();
|
||||
fabricCanvas.renderAll();
|
||||
}
|
||||
|
||||
//===============================================================================
|
||||
// #endregion Fabric.js 캔버스 텍스트 및 이미지 삽입 관련
|
||||
//===============================================================================
|
||||
1
plugin/editor/rb.editor/js/core.js
Normal file
1
plugin/editor/rb.editor/js/core.js
Normal file
File diff suppressed because one or more lines are too long
894
plugin/editor/rb.editor/js/image.js
Normal file
894
plugin/editor/rb.editor/js/image.js
Normal file
@ -0,0 +1,894 @@
|
||||
/* ========== 전역 변수 및 기본 설정 ========== */
|
||||
// 이미지별 효과 상태를 관리하는 Map
|
||||
const imageEffectsMap = new Map();
|
||||
|
||||
let selectedImage = null;
|
||||
let savedToolbarPosition = null;
|
||||
|
||||
// 초기 효과 상태
|
||||
const defaultEffects = {
|
||||
brightness: 100,
|
||||
contrast: 100,
|
||||
saturation: 100,
|
||||
blur: 0,
|
||||
grayscale: 0,
|
||||
invert: 0,
|
||||
opacity: 100,
|
||||
sepia: 0,
|
||||
shadowX: 0,
|
||||
shadowY: 0,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'rgba(0,0,0,0)',
|
||||
radius: 0,
|
||||
borderWidth: 0,
|
||||
borderColor: 'rgba(0,0,0,1)'
|
||||
};
|
||||
|
||||
/* ========== 유틸리티 함수 ========== */
|
||||
/**
|
||||
* 슬라이더 ID에서 효과 키를 추출하는 함수
|
||||
* ex) brightness-slider → brightness
|
||||
*/
|
||||
function getEffectKeyFromSliderId(sliderId) {
|
||||
return sliderId.replace('-slider', '').replace(/-(.)/g, (_, char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
/* ========== 이미지 효과 적용 함수 ========== */
|
||||
/**
|
||||
* 선택된 이미지에 현재 설정된 효과들을 적용하는 함수
|
||||
*/
|
||||
function applyImageEffects() {
|
||||
if (!selectedImage) return;
|
||||
|
||||
const effects = imageEffectsMap.get(selectedImage[0]);
|
||||
const filter = `
|
||||
brightness(${effects.brightness}%)
|
||||
contrast(${effects.contrast}%)
|
||||
saturate(${effects.saturation}%)
|
||||
grayscale(${effects.grayscale}%)
|
||||
invert(${effects.invert}%)
|
||||
opacity(${effects.opacity}%)
|
||||
sepia(${effects.sepia}%)
|
||||
blur(${effects.blur}px)
|
||||
drop-shadow(${effects.shadowX}px ${effects.shadowY}px ${effects.shadowBlur}px ${effects.shadowColor})
|
||||
`;
|
||||
|
||||
selectedImage.css({
|
||||
filter: filter.trim(),
|
||||
'border-radius': `${effects.radius}px`,
|
||||
'border-width': effects.borderWidth + 'px',
|
||||
'border-style': 'solid',
|
||||
'border-color': effects.borderColor
|
||||
});
|
||||
}
|
||||
|
||||
function extractEffectsFromInlineStyle($img) {
|
||||
const effects = { ...defaultEffects };
|
||||
const styleAttr = $img.attr('style'); // ✅ 인라인 스타일 직접 가져오기
|
||||
|
||||
if (styleAttr && styleAttr.includes('filter')) {
|
||||
const filterMatch = styleAttr.match(/filter:\s*([^;]+)/);
|
||||
if (filterMatch) {
|
||||
const filter = filterMatch[1];
|
||||
|
||||
effects.brightness = getFilterValue(filter, 'brightness', '%', 100);
|
||||
effects.contrast = getFilterValue(filter, 'contrast', '%', 100);
|
||||
effects.saturation = getFilterValue(filter, 'saturate', '%', 100);
|
||||
effects.grayscale = getFilterValue(filter, 'grayscale', '%', 0);
|
||||
effects.invert = getFilterValue(filter, 'invert', '%', 0);
|
||||
effects.opacity = getFilterValue(filter, 'opacity', '%', 100);
|
||||
effects.sepia = getFilterValue(filter, 'sepia', '%', 0);
|
||||
effects.blur = getFilterValue(filter, 'blur', 'px', 0);
|
||||
|
||||
// ✅ drop-shadow() 값 추출
|
||||
const dropShadowMatch = filter.match(/drop-shadow\(rgba?\((.*?)\)\s+([\d.]+)px\s+([\d.]+)px\s+([\d.]+)px\)/);
|
||||
if (dropShadowMatch) {
|
||||
effects.shadowColor = `rgba(${dropShadowMatch[1]})`;
|
||||
effects.shadowX = parseFloat(dropShadowMatch[2]);
|
||||
effects.shadowY = parseFloat(dropShadowMatch[3]);
|
||||
effects.shadowBlur = parseFloat(dropShadowMatch[4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ border 관련 속성도 인라인 스타일에서 가져오기
|
||||
effects.radius = getInlineStyleValue($img, 'border-radius', 'px', 0);
|
||||
effects.borderWidth = getInlineStyleValue($img, 'border-width', 'px', 0);
|
||||
effects.borderColor = getInlineBorderColor($img) || 'rgba(0,0,0,1)';
|
||||
|
||||
return effects;
|
||||
}
|
||||
|
||||
function getFilterValue(filterStr, property, unit, defaultValue) {
|
||||
const match = filterStr.match(new RegExp(`${property}\\(([-\\d.]+)${unit}\\)`));
|
||||
return match ? parseFloat(match[1]) : defaultValue;
|
||||
}
|
||||
|
||||
function getInlineStyleValue($img, property, unit, defaultValue) {
|
||||
const style = $img.attr('style');
|
||||
if (!style) return defaultValue;
|
||||
const match = style.match(new RegExp(`${property}:\\s*([-\\d.]+)${unit}`));
|
||||
return match ? parseFloat(match[1]) : defaultValue;
|
||||
}
|
||||
|
||||
function getInlineBorderColor($img) {
|
||||
const style = $img.attr('style');
|
||||
if (!style) return null;
|
||||
const match = style.match(/border-color:\s*(rgb[a]?\([^)]*\))/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
|
||||
/* ========== 이벤트 핸들러 등록 ========== */
|
||||
|
||||
// 이미지 슬라이더 변화에 따른 효과 업데이트
|
||||
$('.image-slider-ui').on('input', function () {
|
||||
if (!selectedImage) return;
|
||||
|
||||
// 현재 선택된 이미지의 효과 상태 가져오기
|
||||
const effects = imageEffectsMap.get(selectedImage[0]);
|
||||
const effectKey = getEffectKeyFromSliderId($(this).attr('id'));
|
||||
|
||||
// 효과 상태 업데이트
|
||||
effects[effectKey] = $(this).val();
|
||||
applyImageEffects(); // 이미지에 효과 적용
|
||||
});
|
||||
|
||||
// 드롭섀도우 색상 변경 이벤트 핸들러
|
||||
$('#shadow-color-slider').on('input', function () {
|
||||
if (!selectedImage) return;
|
||||
|
||||
const transparency = $(this).val(); // 0~100 사이의 값
|
||||
const effects = imageEffectsMap.get(selectedImage[0]);
|
||||
if (!effects) return;
|
||||
|
||||
// alpha 값을 슬라이더 값에 비례하게 계산하여 그림자 컬러 업데이트
|
||||
effects.shadowColor = `rgba(0, 0, 0, ${transparency / 100})`;
|
||||
|
||||
applyImageEffects(); // 변경된 효과를 이미지에 적용
|
||||
});
|
||||
|
||||
// 테두리 색상 변경 이벤트 핸들러
|
||||
$('#border-color-slider').on('input', function () {
|
||||
if (!selectedImage) return;
|
||||
|
||||
const transparency = $(this).val(); // 0~100 사이의 값
|
||||
const effects = imageEffectsMap.get(selectedImage[0]);
|
||||
if (!effects) return;
|
||||
|
||||
// alpha 값을 투명도로 계산하여 borderColor 업데이트
|
||||
effects.borderColor = `rgba(0, 0, 0, ${transparency / 100})`;
|
||||
|
||||
applyImageEffects(); // 변경된 효과를 이미지에 적용
|
||||
});
|
||||
|
||||
// 효과 초기화 버튼 클릭 이벤트 핸들러
|
||||
$('#reset-effects-btn').click(function () {
|
||||
if (!selectedImage) return;
|
||||
|
||||
// 선택된 이미지의 효과 상태를 기본값으로 초기화
|
||||
const effects = imageEffectsMap.get(selectedImage[0]);
|
||||
Object.assign(effects, defaultEffects);
|
||||
|
||||
// 슬라이더와 효과 상태 동기화
|
||||
syncSlidersWithEffects(effects);
|
||||
applyImageEffects();
|
||||
});
|
||||
|
||||
/* ========== 이미지 선택 및 툴바 관련 함수 ========== */
|
||||
/**
|
||||
* 이미지를 선택했을 때 호출되는 함수
|
||||
* 선택된 이미지에 대한 효과 상태를 불러오고, 슬라이더 UI와 동기화합니다.
|
||||
*/
|
||||
function selectImage(image) {
|
||||
selectedImage = image;
|
||||
|
||||
let effects = imageEffectsMap.get(image[0]);
|
||||
if (!effects) {
|
||||
effects = extractEffectsFromInlineStyle(image);
|
||||
imageEffectsMap.set(image[0], effects);
|
||||
}
|
||||
|
||||
syncSlidersWithEffects(effects);
|
||||
}
|
||||
|
||||
function syncSlidersWithEffects(effects) {
|
||||
$('#brightness-slider').val(effects.brightness);
|
||||
$('#contrast-slider').val(effects.contrast);
|
||||
$('#saturation-slider').val(effects.saturation);
|
||||
$('#blur-slider').val(effects.blur);
|
||||
$('#grayscale-slider').val(effects.grayscale);
|
||||
$('#invert-slider').val(effects.invert);
|
||||
$('#opacity-slider').val(effects.opacity);
|
||||
$('#sepia-slider').val(effects.sepia);
|
||||
$('#shadow-x-slider').val(effects.shadowX);
|
||||
$('#shadow-y-slider').val(effects.shadowY);
|
||||
$('#shadow-blur-slider').val(effects.shadowBlur);
|
||||
$('#radius-slider').val(effects.radius);
|
||||
$('#border-width-slider').val(effects.borderWidth);
|
||||
|
||||
// ✅ shadowColor (rgba -> alpha 값만 슬라이더에 적용)
|
||||
if (effects.shadowColor) {
|
||||
const shadowMatch = effects.shadowColor.match(/rgba?\(\d+,\s*\d+,\s*\d+,\s*([\d.]+)\)/);
|
||||
if (shadowMatch) {
|
||||
$('#shadow-color-slider').val(parseFloat(shadowMatch[1]) * 100); // 0~1 -> 0~100 변환
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ borderColor (rgba -> alpha 값만 슬라이더에 적용)
|
||||
if (effects.borderColor) {
|
||||
const borderMatch = effects.borderColor.match(/rgba?\(\d+,\s*\d+,\s*\d+,\s*([\d.]+)\)/);
|
||||
if (borderMatch) {
|
||||
$('#border-color-slider').val(parseFloat(borderMatch[1]) * 100); // 0~1 -> 0~100 변환
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 슬라이더 조정 시 이미지 툴바를 계속 표시하는 함수
|
||||
*/
|
||||
function keepImageToolbarVisible() {
|
||||
if (selectedImage) {
|
||||
$('#image-toolbar').fadeIn(0); // 슬라이더 조정 시 툴바 표시 유지
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$('#editor').on('mousedown', '.resizable', function (e) {
|
||||
// 크기 조절 핸들이 아닌 영역에서 커서 방지
|
||||
const isHandle = $(e.target).hasClass('resize-handle');
|
||||
if (!isHandle) {
|
||||
e.preventDefault(); // 기본 동작 방지
|
||||
}
|
||||
|
||||
// `.resize-handle`이 없는 경우 추가
|
||||
if ($(this).children('.resize-handle').length === 0) {
|
||||
$(this).append('<div class="resize-handle"></div>');
|
||||
// 새로 추가된 핸들에 대해 커스텀 리사이징 이벤트를 다시 바인딩
|
||||
makeImageResizableWithObserver($(this));
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('mousedown touchstart', '.resizable img', function(e) {
|
||||
// 터치 이벤트의 경우, 두 손가락 이상이면 panning 동작 실행하지 않음
|
||||
if (e.type === 'touchstart' && e.touches.length > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 리사이즈 핸들 내부에서 발생한 이벤트는 무시하여 충돌 방지
|
||||
if ($(e.target).closest('.resize-handle').length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
var $img = $(this);
|
||||
// 부모 영역에 꽉 차도록 설정 (빈 공간 방지)
|
||||
$img.css({
|
||||
'width': '100%'
|
||||
});
|
||||
|
||||
// 시작 좌표 (마우스/터치 구분)
|
||||
var startX = e.type === 'mousedown' ? e.clientX : e.touches[0].clientX;
|
||||
var startY = e.type === 'mousedown' ? e.clientY : e.touches[0].clientY;
|
||||
|
||||
// .resizable (부모) 크기
|
||||
var $parent = $img.closest('.resizable');
|
||||
var containerWidth = $parent.width();
|
||||
var containerHeight = $parent.height();
|
||||
|
||||
// 이전 드래그에서 저장된 object-position이 있다면 사용, 없으면 기본값 50% 50%
|
||||
var storedPos = $img.data('initialObjPos');
|
||||
var initialObjPos = storedPos ? storedPos : window.getComputedStyle($img[0]).objectPosition;
|
||||
var initialX = 50, initialY = 50;
|
||||
if (initialObjPos && initialObjPos.split(' ').length === 2) {
|
||||
var parts = initialObjPos.split(' ');
|
||||
var parsedX = parseFloat(parts[0]);
|
||||
var parsedY = parseFloat(parts[1]);
|
||||
initialX = isNaN(parsedX) ? 50 : parsedX;
|
||||
initialY = isNaN(parsedY) ? 50 : parsedY;
|
||||
}
|
||||
|
||||
// 이미지 자연 크기
|
||||
var naturalWidth = $img[0].naturalWidth;
|
||||
var naturalHeight = $img[0].naturalHeight;
|
||||
|
||||
// object-fit: cover 적용 시, 실제 렌더링되는 이미지 크기 계산
|
||||
var scale = Math.max(containerWidth / naturalWidth, containerHeight / naturalHeight);
|
||||
var displayedWidth = naturalWidth * scale;
|
||||
var displayedHeight = naturalHeight * scale;
|
||||
|
||||
// 컨테이너 대비 이미지의 여분 (panning 가능한 범위)
|
||||
var extraWidth = displayedWidth - containerWidth;
|
||||
var extraHeight = displayedHeight - containerHeight;
|
||||
|
||||
var dragData = { startX, startY, initialX, initialY, extraWidth, extraHeight };
|
||||
|
||||
function onMove(e) {
|
||||
var moveX = e.type === 'mousemove' ? e.clientX : e.touches[0].clientX;
|
||||
var moveY = e.type === 'mousemove' ? e.clientY : e.touches[0].clientY;
|
||||
var dx = moveX - dragData.startX;
|
||||
var dy = moveY - dragData.startY;
|
||||
|
||||
var newX = dragData.initialX;
|
||||
var newY = dragData.initialY;
|
||||
if (dragData.extraWidth > 0) {
|
||||
newX = dragData.initialX - (dx * 100 / dragData.extraWidth);
|
||||
}
|
||||
if (dragData.extraHeight > 0) {
|
||||
newY = dragData.initialY - (dy * 100 / dragData.extraHeight);
|
||||
}
|
||||
|
||||
newX = Math.max(0, Math.min(100, newX));
|
||||
newY = Math.max(0, Math.min(100, newY));
|
||||
|
||||
$img.css('object-position', newX + '% ' + newY + '%');
|
||||
}
|
||||
|
||||
function onEnd(e) {
|
||||
$(document).off('mousemove touchmove', onMove);
|
||||
$(document).off('mouseup touchend', onEnd);
|
||||
// 드래그 종료 시 최종 object-position 값을 저장하여 다음 드래그의 초기값으로 사용
|
||||
var finalObjPos = $img.css('object-position');
|
||||
$img.data('initialObjPos', finalObjPos);
|
||||
}
|
||||
|
||||
$(document).on('mousemove touchmove', onMove);
|
||||
$(document).on('mouseup touchend', onEnd);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
$('#editor').on('click', '.resizable', function (e) {
|
||||
e.stopPropagation();
|
||||
|
||||
// 이전 선택된 이미지 해제
|
||||
$('.resizable').removeClass('selected');
|
||||
$(this).addClass('selected');
|
||||
|
||||
// 선택된 이미지로 효과 동기화
|
||||
selectImage($(this).find('img'));
|
||||
showImageToolbar($(this)); // 툴바 표시
|
||||
|
||||
// ▼ 추가: 이미지 링크 정보 확인 후 입력창에 반영
|
||||
const $img = $(this).find('img');
|
||||
const $anchor = $img.parent('a'); // 부모가 <a>인지 검사
|
||||
if ($anchor.length) {
|
||||
// 링크가 있으면 href, target 정보를 UI에 반영
|
||||
const hrefVal = $anchor.attr('href') || '';
|
||||
const targetVal = $anchor.attr('target') || '_parent';
|
||||
|
||||
$('#rb-image-link-inp').text(hrefVal);
|
||||
$('#rb-image-link-blanks').prop('checked', targetVal === '_blank');
|
||||
} else {
|
||||
// 링크가 없으면 입력창 초기화
|
||||
$('#rb-image-link-inp').text('');
|
||||
$('#rb-image-link-blanks').prop('checked', false);
|
||||
}
|
||||
|
||||
document.activeElement.blur();
|
||||
|
||||
});
|
||||
|
||||
$('#mini-image-link-del-btn').on('click', function () {
|
||||
// 선택된 .resizable 내부의 img
|
||||
const $selectedImg = $('.resizable.selected img');
|
||||
if (!$selectedImg.length) {
|
||||
alert('이미지를 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
$selectedImg.each(function () {
|
||||
let $img = $(this);
|
||||
let $parent = $img.parent();
|
||||
if ($parent.is('a')) {
|
||||
// <a> 태그 제거, <img>만 남김
|
||||
$parent.replaceWith($img);
|
||||
}
|
||||
});
|
||||
|
||||
// 입력창 초기화
|
||||
$('#rb-image-link-inp').text('');
|
||||
$('#rb-image-link-blanks').prop('checked', false);
|
||||
});
|
||||
|
||||
$('#image-toolbar').on('click', function (e) {
|
||||
e.stopPropagation(); // 이벤트 전파 방지
|
||||
});
|
||||
|
||||
// #image-toolbar 드래그(마우스 & 터치)로 이동 가능하게 하기
|
||||
$('#image-toolbar #move_handle').on('mousedown touchstart', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var $handle = $(this);
|
||||
var $toolbar = $handle.closest('#image-toolbar');
|
||||
var $container = $('#editor-container');
|
||||
var containerOffset = $container.offset();
|
||||
|
||||
// 드래그 시작 시의 좌표 (마우스/터치 구분)
|
||||
var startX = e.type === 'mousedown' ? e.clientX : e.touches[0].clientX;
|
||||
var startY = e.type === 'mousedown' ? e.clientY : e.touches[0].clientY;
|
||||
|
||||
// 현재 툴바의 상대적 위치 (컨테이너 기준)
|
||||
var toolbarOffset = $toolbar.offset();
|
||||
var initialLeft = toolbarOffset.left - containerOffset.left;
|
||||
var initialTop = toolbarOffset.top - containerOffset.top;
|
||||
|
||||
function onMove(e) {
|
||||
var moveX = e.type === 'mousemove' ? e.clientX : e.touches[0].clientX;
|
||||
var moveY = e.type === 'mousemove' ? e.clientY : e.touches[0].clientY;
|
||||
var dx = moveX - startX;
|
||||
var dy = moveY - startY;
|
||||
|
||||
// 새 위치 (컨테이너 기준)
|
||||
var newLeft = initialLeft + dx;
|
||||
var newTop = initialTop + dy;
|
||||
|
||||
// 경계 체크 (컨테이너 내에 머물도록)
|
||||
var containerWidth = $container.width();
|
||||
var containerHeight = $container.height();
|
||||
var toolbarWidth = $toolbar.outerWidth();
|
||||
var toolbarHeight = $toolbar.outerHeight();
|
||||
|
||||
newLeft = Math.max(0, Math.min(newLeft, containerWidth - toolbarWidth));
|
||||
newTop = Math.max(0, Math.min(newTop, containerHeight - toolbarHeight));
|
||||
|
||||
$toolbar.css({
|
||||
left: newLeft + 'px',
|
||||
top: newTop + 'px'
|
||||
});
|
||||
|
||||
// 이동한 위치 저장 (컨테이너 기준)
|
||||
savedToolbarPosition = { left: newLeft, top: newTop };
|
||||
}
|
||||
|
||||
function onEnd(e) {
|
||||
$(document).off('mousemove touchmove', onMove);
|
||||
$(document).off('mouseup touchend', onEnd);
|
||||
}
|
||||
|
||||
$(document).on('mousemove touchmove', onMove);
|
||||
$(document).on('mouseup touchend', onEnd);
|
||||
});
|
||||
|
||||
|
||||
/* 3. 이미지 리사이징 기능 (커스텀) */
|
||||
function makeImageResizableWithObserver($resizable) {
|
||||
makeImageResizable($resizable);
|
||||
observeResizable($resizable);
|
||||
}
|
||||
|
||||
|
||||
function makeImageResizable($resizable) {
|
||||
$resizable.removeData('resizable');
|
||||
$resizable.data('resizable', true);
|
||||
$resizable.css('position', 'relative');
|
||||
|
||||
let $handle = $resizable.find('.resize-handle');
|
||||
if ($handle.length === 0) {
|
||||
$handle = $('<div class="resize-handle"></div>');
|
||||
$resizable.append($handle);
|
||||
} else {
|
||||
$handle.off('mousedown touchstart');
|
||||
}
|
||||
|
||||
function startResize(e, isTouch) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const $content = $resizable.find('img, iframe'); // ✅ 이미지 또는 iframe
|
||||
if ($content.length === 0) return; // ✅ 콘텐츠 없으면 종료
|
||||
|
||||
let startWidth = $resizable.width();
|
||||
let startHeight = $resizable.height();
|
||||
|
||||
// ✅ 콘텐츠 크기 가져오기 (이미지: naturalWidth, iframe: getBoundingClientRect)
|
||||
let originalWidth = $content.is('img') ? $content[0].naturalWidth : $content[0].getBoundingClientRect().width;
|
||||
let originalHeight = $content.is('img') ? $content[0].naturalHeight : $content[0].getBoundingClientRect().height;
|
||||
let originalRatio = originalHeight / originalWidth;
|
||||
|
||||
let startX = 0, startY = 0;
|
||||
let initialPinchDistance = null;
|
||||
let shiftKeyPressed = e.shiftKey;
|
||||
|
||||
if (isTouch && e.touches.length >= 2) {
|
||||
initialPinchDistance = getPinchDistance(e);
|
||||
} else {
|
||||
startX = isTouch ? e.touches[0].clientX : e.clientX;
|
||||
startY = isTouch ? e.touches[0].clientY : e.clientY;
|
||||
}
|
||||
|
||||
// ✅ `.url-preview-video` 내부일 경우 iframe 포인터 비활성화
|
||||
if ($resizable.closest('.url-preview-video').length > 0) {
|
||||
$resizable.closest('.url-preview-video').find('iframe').css('pointer-events', 'none');
|
||||
}
|
||||
|
||||
function doResize(e) {
|
||||
e.preventDefault();
|
||||
|
||||
let newWidth = startWidth;
|
||||
let newHeight = startHeight;
|
||||
shiftKeyPressed = e.shiftKey;
|
||||
|
||||
if (isTouch && e.touches.length >= 2) {
|
||||
let currentPinchDistance = getPinchDistance(e);
|
||||
if (initialPinchDistance && currentPinchDistance) {
|
||||
let scaleFactor = currentPinchDistance / initialPinchDistance;
|
||||
newWidth = startWidth * scaleFactor;
|
||||
newHeight = shiftKeyPressed ? newWidth * originalRatio : startHeight * scaleFactor;
|
||||
}
|
||||
if ($content.is('img')) {
|
||||
$content.css('object-fit', ''); // ✅ 이미지 비율 유지
|
||||
}
|
||||
} else {
|
||||
const moveX = isTouch ? e.touches[0].clientX : e.clientX;
|
||||
const moveY = isTouch ? e.touches[0].clientY : e.clientY;
|
||||
const dx = moveX - startX;
|
||||
const dy = moveY - startY;
|
||||
|
||||
if (shiftKeyPressed || $resizable.closest('.url-preview-video').length > 0) {
|
||||
// ✅ Shift 키가 눌렸거나 `.url-preview-video` 내부일 경우 원본 비율 유지
|
||||
newWidth = startWidth + dx;
|
||||
newHeight = newWidth * originalRatio;
|
||||
if ($content.is('img')) {
|
||||
$content.css('object-fit', ''); // ✅ 이미지 비율 유지
|
||||
}
|
||||
} else {
|
||||
// ✅ 자유 크기 조절 모드 (iframe 포함)
|
||||
newWidth = startWidth + dx;
|
||||
newHeight = startHeight + dy;
|
||||
if ($content.is('img')) {
|
||||
$content.css('object-fit', 'cover'); // ✅ 비율 유지 없음
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newWidth > 20 && newHeight > 20) {
|
||||
$resizable.css({
|
||||
width: newWidth + 'px',
|
||||
height: newHeight + 'px'
|
||||
});
|
||||
|
||||
// ✅ Shift 키, 투핑거 터치, `.url-preview-video` 내부에서는 원본 비율 유지
|
||||
if (shiftKeyPressed || (isTouch && e.touches.length >= 2) || $resizable.closest('.url-preview-video').length > 0) {
|
||||
newHeight = newWidth * originalRatio;
|
||||
$resizable.css({ height: newHeight + 'px' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
document.removeEventListener(isTouch ? 'touchmove' : 'mousemove', doResize);
|
||||
document.removeEventListener(isTouch ? 'touchend' : 'mouseup', stopResize);
|
||||
|
||||
let finalWidth = $resizable.width();
|
||||
let finalHeight = $resizable.height();
|
||||
|
||||
// ✅ 최종 크기 저장
|
||||
$resizable.attr('data-original-width', finalWidth);
|
||||
$resizable.attr('data-original-height', finalHeight);
|
||||
$resizable.attr('data-ratio', finalHeight / finalWidth);
|
||||
|
||||
// ✅ `.url-preview-video` 내부일 경우 iframe 포인터 이벤트 복구
|
||||
if ($resizable.closest('.url-preview-video').length > 0) {
|
||||
$resizable.closest('.url-preview-video').find('iframe').css('pointer-events', '');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener(isTouch ? 'touchmove' : 'mousemove', doResize, { passive: false });
|
||||
document.addEventListener(isTouch ? 'touchend' : 'mouseup', stopResize, { passive: false });
|
||||
}
|
||||
|
||||
const handleEl = $handle[0];
|
||||
handleEl.addEventListener('mousedown', function(e) {
|
||||
startResize(e, false);
|
||||
}, { passive: false });
|
||||
handleEl.addEventListener('touchstart', function(e) {
|
||||
startResize(e, true);
|
||||
}, { passive: false });
|
||||
|
||||
$resizable.on('touchstart', function(e) {
|
||||
if (e.touches.length >= 2 && $(e.target).closest('.resize-handle').length === 0) {
|
||||
startResize(e, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 헬퍼: 두 손가락 사이 거리를 계산하는 함수
|
||||
function getPinchDistance(e) {
|
||||
if (e.touches.length >= 2) {
|
||||
var dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
var dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* 4. MutationObserver를 사용하여 'resizable' div의 클래스 변경 감지 */
|
||||
const observer = new MutationObserver(function (mutationsList) {
|
||||
mutationsList.forEach(mutation => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
const $target = $(mutation.target);
|
||||
if ($target.hasClass('selected')) {
|
||||
showImageToolbar($target);
|
||||
} else {
|
||||
$('#image-toolbar').fadeOut(0);
|
||||
selectedImage = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 모든 'resizable' div에 대해 MutationObserver 설정
|
||||
function observeResizable($resizable) {
|
||||
observer.observe($resizable[0], { attributes: true });
|
||||
}
|
||||
|
||||
function showImageToolbar($resizable) {
|
||||
selectedImage = $resizable.find('img');
|
||||
|
||||
// 저장된 위치가 있으면 그대로 사용
|
||||
if (savedToolbarPosition) {
|
||||
$('#image-toolbar').css({
|
||||
top: savedToolbarPosition.top + 'px',
|
||||
left: savedToolbarPosition.left + 'px',
|
||||
}).fadeIn(0);
|
||||
} else {
|
||||
// 기존 로직에 따른 위치 계산
|
||||
const imgOffset = $resizable.offset();
|
||||
const containerOffset = $('body').offset();
|
||||
const toolbarHeight = $('#image-toolbar').outerHeight();
|
||||
const toolbarWidth = $('#image-toolbar').outerWidth();
|
||||
const containerWidth = $('#editor-container').width();
|
||||
const containerHeight = $('#editor-container').height();
|
||||
|
||||
let toolbarTop = imgOffset.top - containerOffset.top + 15;
|
||||
let toolbarLeft = selectedImage.width() + 50;
|
||||
if (toolbarTop + toolbarHeight > containerHeight) {
|
||||
toolbarTop = imgOffset.top - containerOffset.top - toolbarHeight;
|
||||
}
|
||||
if (toolbarLeft + toolbarWidth > containerWidth) {
|
||||
toolbarLeft = containerWidth - toolbarWidth;
|
||||
}
|
||||
if (toolbarLeft < 0) {
|
||||
toolbarLeft = 10;
|
||||
}
|
||||
if (toolbarTop < 0) {
|
||||
toolbarTop = 10;
|
||||
}
|
||||
|
||||
$('#image-toolbar').css({
|
||||
top: toolbarTop + 'px',
|
||||
left: toolbarLeft + 'px',
|
||||
}).fadeIn(0);
|
||||
}
|
||||
}
|
||||
|
||||
$('#radius-slider').on('input', function () {
|
||||
if ($('.resizable.selected').length) {
|
||||
const radiusValue = $(this).val();
|
||||
$('.resizable.selected img').css('border-radius', radiusValue + 'px');
|
||||
}
|
||||
});
|
||||
|
||||
$('#remove-image-btn').click(function () {
|
||||
if (!selectedImage) return;
|
||||
|
||||
if (currentMode === 'regular') {
|
||||
const $img = $(selectedImage);
|
||||
|
||||
// 1) 가장 먼저 .resizable_wrap, .resizable를 찾고
|
||||
let $target = $img.closest('.resizable_wrap, .resizable');
|
||||
|
||||
// 2) 없다면 <a> 태그가 있는지 확인
|
||||
if (!$target.length) {
|
||||
$target = $img.closest('a');
|
||||
}
|
||||
|
||||
// 3) 그래도 없으면 자기 자신(img)만 제거
|
||||
if ($target.length) {
|
||||
$target.remove();
|
||||
} else {
|
||||
$img.remove();
|
||||
}
|
||||
}
|
||||
else if (currentMode === 'canvas') {
|
||||
fabricCanvas.remove(selectedImage);
|
||||
}
|
||||
|
||||
$('#image-toolbar').fadeOut(0);
|
||||
selectedImage = null;
|
||||
});
|
||||
|
||||
|
||||
/*
|
||||
$('#remove-server-image-btn').click(function () {
|
||||
if (!selectedImage) return;
|
||||
|
||||
let imageUrl = selectedImage.attr('src');
|
||||
|
||||
// 삭제 확인 메시지
|
||||
if (!confirm("서버에서 이미지를 완전히 삭제합니다. 삭제된 이미지는 복구되지 않으며, 이미지를 감싸는 영역도 함께 제거됩니다.\n\n이미지를 서버에서 완전히 삭제 하시겠습니까?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
var g5_editor_url = "/plugin/editor/rb.editor"; // 올바른 경로 설정
|
||||
|
||||
// 서버에서 파일 삭제 요청
|
||||
$.ajax({
|
||||
url: g5_editor_url + "/php/rb.delete.php", // 삭제 처리 파일
|
||||
type: "POST",
|
||||
data: { file: imageUrl }, // 파일 URL 전송
|
||||
dataType: "json",
|
||||
success: function (response) {
|
||||
console.log("서버 응답 (파일 삭제):", response);
|
||||
|
||||
if (response.success) {
|
||||
// ✅ 서버에서 파일 삭제 성공 시 `.resizable` 삭제
|
||||
selectedImage.parent('.resizable').remove();
|
||||
|
||||
// 툴바 숨김 및 선택 이미지 초기화
|
||||
$('#image-toolbar').fadeOut(0);
|
||||
selectedImage = null;
|
||||
} else {
|
||||
alert("이미지 삭제 실패: " + (response.error || "알 수 없는 오류"));
|
||||
}
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
console.error("파일 삭제 오류:", error);
|
||||
alert("파일 삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
$(document).click(function (e) {
|
||||
if (!$(e.target).closest('#image-toolbar').length && !$(e.target).closest('.resizable').length) {
|
||||
$('#image-toolbar').fadeOut(0);
|
||||
selectedImage = null;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 커서를 박스 외부로 강제 이동하는 함수
|
||||
function moveCursorOutsideResizable(range) {
|
||||
const resizable = $(range.startContainer).closest('.resizable');
|
||||
|
||||
if (resizable.length) {
|
||||
// 박스 바로 뒤로 커서를 이동
|
||||
const parentNode = resizable[0].parentNode;
|
||||
const resizableIndex = Array.from(parentNode.childNodes).indexOf(resizable[0]);
|
||||
|
||||
// 새로운 Range 설정
|
||||
const newRange = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (parentNode.childNodes[resizableIndex + 1]) {
|
||||
// 박스 뒤로 이동
|
||||
newRange.setStart(parentNode.childNodes[resizableIndex + 1], 0);
|
||||
} else {
|
||||
// 박스가 마지막 노드라면 부모의 끝으로 이동
|
||||
newRange.setStartAfter(resizable[0]);
|
||||
}
|
||||
newRange.collapse(true);
|
||||
|
||||
// 선택된 Range 업데이트
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
}
|
||||
}
|
||||
|
||||
function wrapImagesWithResizable() {
|
||||
$('#editor img').not('.url-preview img').each(function () {
|
||||
var $img = $(this);
|
||||
var $resizable, $resizableWrap;
|
||||
|
||||
// 이미 `.resizable_wrap`으로 감싸져 있는지 확인
|
||||
if ($img.closest('.resizable_wrap').length) {
|
||||
$resizableWrap = $img.closest('.resizable_wrap');
|
||||
$resizable = $resizableWrap.find('.resizable');
|
||||
} else {
|
||||
// ✅ `.resizable_wrap` 생성
|
||||
$resizableWrap = $('<div class="resizable_wrap"></div>');
|
||||
|
||||
// ✅ `.resizable` 생성
|
||||
$resizable = $('<div class="resizable" contenteditable="false" draggable="true"></div>');
|
||||
|
||||
// ✅ `.resizable` 기본 스타일 적용
|
||||
$resizable.css({
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
caretColor: 'transparent',
|
||||
pointerEvents: 'auto'
|
||||
});
|
||||
|
||||
// ✅ 크기 조절 핸들 추가
|
||||
var $resizeHandle = $('<div class="resize-handle"></div>');
|
||||
$resizable.append($img.clone()).append($resizeHandle);
|
||||
|
||||
// ✅ 구조 변경: `.resizable_wrap` → `.resizable` → `<img>`
|
||||
$resizableWrap.append($resizable);
|
||||
$img.replaceWith($resizableWrap);
|
||||
|
||||
// ✅ 크기 조절 기능 활성화
|
||||
makeImageResizableWithObserver($resizable);
|
||||
}
|
||||
|
||||
// ✅ 현재 `.resizable`의 크기 저장
|
||||
var currentWidth = $resizable.width();
|
||||
var currentHeight = $resizable.height();
|
||||
$resizable.attr('data-original-width', currentWidth);
|
||||
$resizable.attr('data-original-height', currentHeight);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
$(document).ready(function () {
|
||||
|
||||
/* 완전삭제를 사용하는 경우 함수교체
|
||||
function initResizableElements() {
|
||||
$('.resizable').each(function () {
|
||||
var $this = $(this);
|
||||
var $img = $this.find('img');
|
||||
|
||||
// 이미지가 없거나, 이미지가 로드되지 않으면 .resizable 제거
|
||||
if ($img.length === 0) {
|
||||
console.warn("이미지 없음 - .resizable 삭제", $this);
|
||||
$this.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미지가 존재하는지 확인 (비동기 방식)
|
||||
checkImageExists($img.attr('src'), function(exists) {
|
||||
if (!exists) {
|
||||
console.warn("이미지 로드 실패 - .resizable 삭제", $this);
|
||||
$this.remove();
|
||||
}
|
||||
});
|
||||
|
||||
observeResizable($this);
|
||||
makeImageResizableWithObserver($this);
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ 이미지 존재 여부 확인 함수 (fetch 사용)
|
||||
function checkImageExists(imageUrl, callback) {
|
||||
fetch(imageUrl, { method: 'HEAD' })
|
||||
.then(response => callback(response.ok))
|
||||
.catch(() => callback(false));
|
||||
}
|
||||
*/
|
||||
|
||||
function initResizableElements() {
|
||||
$('.resizable').each(function () {
|
||||
var $this = $(this);
|
||||
observeResizable($this);
|
||||
makeImageResizableWithObserver($this);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 최초 실행 (초기 로드된 .resizable 요소 감지)
|
||||
initResizableElements();
|
||||
wrapImagesWithResizable();
|
||||
|
||||
// MutationObserver 설정 (동적 감지)
|
||||
const observer = new MutationObserver(() => {
|
||||
initResizableElements();
|
||||
wrapImagesWithResizable();
|
||||
});
|
||||
|
||||
// #editor 내부에서 변경 사항 감지
|
||||
observer.observe(document.getElementById('editor'), { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
219
plugin/editor/rb.editor/js/metadata.js
Normal file
219
plugin/editor/rb.editor/js/metadata.js
Normal file
@ -0,0 +1,219 @@
|
||||
|
||||
var urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
var imageRegex = /\.(jpeg|jpg|gif|png|webp|svg)$/i;
|
||||
var pendingRequests = {};
|
||||
|
||||
function extractVideoId(url) {
|
||||
let videoId = null;
|
||||
let timeParam = '';
|
||||
|
||||
if (url.includes('youtube.com') || url.includes('youtu.be')) {
|
||||
let match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/)([\w-]+)/);
|
||||
videoId = match ? match[1] : null;
|
||||
let timeMatch = url.match(/[?&]t=(\d+)/);
|
||||
timeParam = timeMatch ? `?start=${timeMatch[1]}` : '';
|
||||
return videoId ? `https://www.youtube.com/embed/${videoId}${timeParam}` : null;
|
||||
}
|
||||
|
||||
if (url.includes('vimeo.com')) {
|
||||
let match = url.match(/vimeo\.com\/(\d+)/);
|
||||
videoId = match ? match[1] : null;
|
||||
return videoId ? `https://player.vimeo.com/video/${videoId}` : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function fetchMetadataAndDisplay(url, range) {
|
||||
// <pre> 내부에서 실행되지 않도록 차단
|
||||
if ($(range.commonAncestorContainer).closest('pre').length > 0) {
|
||||
console.warn("🚫 <pre> 내부에서는 미디어 또는 메타데이터를 삽입할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
let embedUrl = extractVideoId(url);
|
||||
|
||||
if ($(range.commonAncestorContainer).closest('table, td, th').length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (embedUrl) {
|
||||
displayVideoPreview(url, embedUrl, range);
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: g5Config.g5_editor_url + '/php/rb.metadata.php',
|
||||
method: 'GET',
|
||||
data: { url: url },
|
||||
dataType: 'json',
|
||||
success: function (response) {
|
||||
if (response.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
var meta = response.meta;
|
||||
var title = response.title || '제목 없음';
|
||||
var description = meta['description'] || meta['og:description'] || '';
|
||||
var image = meta['og:image'] || '';
|
||||
|
||||
var wrapper = $('<div class="url-preview-meta"></div>'); // 메타데이터 감싸는 div
|
||||
var html = $('<div class="url-preview" data-url="' + url + '" contenteditable="false"></div>');
|
||||
|
||||
var imageWrapper = $('<li class="proxyImg"></li>'); // 이미지 감싸는 요소
|
||||
var metaData = $('<li class="metaData"></li>');
|
||||
|
||||
function validateImageUrl(imageUrl, callback) {
|
||||
var img = new Image();
|
||||
img.src = imageUrl;
|
||||
img.onload = function () {
|
||||
callback(true); // 이미지 로드 성공
|
||||
};
|
||||
img.onerror = function () {
|
||||
callback(false); // 이미지 로드 실패
|
||||
};
|
||||
}
|
||||
|
||||
if (image) {
|
||||
var imageProxyUrl = g5Config.g5_editor_url + '/php/rb.image_proxy.php?url=' + encodeURIComponent(image);
|
||||
var imageElement = $('<a href="' + url + '" target="_blank"><img src="' + imageProxyUrl + '" alt="Preview Image"></a>');
|
||||
|
||||
validateImageUrl(imageProxyUrl, function (isValid) {
|
||||
if (isValid) {
|
||||
imageWrapper.append(imageElement);
|
||||
html.append(imageWrapper);
|
||||
} else {
|
||||
imageWrapper.remove(); // 엑박 방지: 이미지가 없으면 제거
|
||||
metaData.css("padding-left", "0"); // 자동으로 padding-left 제거
|
||||
}
|
||||
});
|
||||
|
||||
imageElement.on('error', function () {
|
||||
imageWrapper.remove(); // 이미지가 깨지면 자동 제거
|
||||
metaData.css("padding-left", "0"); // padding-left: 0 적용
|
||||
});
|
||||
} else {
|
||||
metaData.css("padding-left", "0"); // 이미지가 없으면 자동 적용
|
||||
}
|
||||
|
||||
metaData.append('<h3>' + title + '</h3>');
|
||||
metaData.append('<p>' + description + '</p>');
|
||||
metaData.append('<a href="' + url + '" target="_blank" style="padding-top:5px;">' + url + '</a>');
|
||||
|
||||
html.append(metaData);
|
||||
wrapper.append(html);
|
||||
|
||||
insertMediaAfterUrl(wrapper, range);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function displayVideoPreview(url, embedUrl, range) {
|
||||
// <pre> 내부에서는 동영상 미리보기를 삽입하지 않도록 제한
|
||||
if ($(range.commonAncestorContainer).closest('pre').length > 0) {
|
||||
//console.warn("<pre> 내부에서는 동영상 미리보기를 삽입할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if ($(range.commonAncestorContainer).closest('table, td, th').length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var wrapper = $('<div class="url-preview-video"></div>'); // ✅ 동영상 감싸는 div
|
||||
var html = $('<div class="url-preview resizable" data-url="' + url + '" contenteditable="false"></div>');
|
||||
html.append('<div class="rb-video-container"><iframe id="meta-iframe-video" src="' + embedUrl + '" frameborder="0" allowfullscreen></iframe></div>');
|
||||
|
||||
wrapper.append(html); // 감싸는 div 추가
|
||||
insertMediaAfterUrl(wrapper, range);
|
||||
}
|
||||
|
||||
|
||||
function insertMediaAfterUrl(node, range) {
|
||||
range.collapse(false);
|
||||
range.insertNode(node[0]);
|
||||
range.collapse(false);
|
||||
|
||||
var selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
var newRange = document.createRange();
|
||||
newRange.setStartAfter(node[0]);
|
||||
newRange.collapse(true);
|
||||
selection.addRange(newRange);
|
||||
|
||||
// 미디어 노드 내의 .resizable 요소에 대해 현재 크기를 data 속성으로 업데이트
|
||||
node.find('.resizable').each(function(){
|
||||
var $this = $(this);
|
||||
var currentWidth = $this.width();
|
||||
var currentHeight = $this.height();
|
||||
$this.attr('data-original-width', currentWidth);
|
||||
$this.attr('data-original-height', currentHeight);
|
||||
// 필요시 data-ratio도 갱신 (세로/가로)
|
||||
$this.attr('data-ratio', currentHeight / currentWidth);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
function insertImageDirectly(url, range) {
|
||||
if ($(range.commonAncestorContainer).closest('table, td, th').length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var wrapper = $('<div class="url-preview-img"></div>'); // ✅ 이미지 감싸는 div
|
||||
var html = $('<div class="url-preview resizable" data-url="' + url + '" contenteditable="false"></div>');
|
||||
var img = $('<img src="' + url + '" alt="Embedded Image">');
|
||||
|
||||
img.on('load', function () {
|
||||
// ✅ 이미지 원본 크기 저장
|
||||
var imgWidth = this.naturalWidth;
|
||||
var imgHeight = this.naturalHeight;
|
||||
var ratio = imgHeight / imgWidth;
|
||||
|
||||
const editorWidth = $('#editor').width(); // 에디터의 가로 크기
|
||||
|
||||
// ✅ 에디터보다 크면 width: 100%, 작으면 원본 크기 유지
|
||||
if (imgWidth > editorWidth) {
|
||||
html.css('width', '100%');
|
||||
} else {
|
||||
html.css('width', imgWidth + 'px');
|
||||
}
|
||||
|
||||
// ✅ 원본 크기 저장 (비율 유지용)
|
||||
html.attr('data-original-width', imgWidth);
|
||||
html.attr('data-original-height', imgHeight);
|
||||
html.attr('data-ratio', ratio);
|
||||
|
||||
// ✅ 높이 설정 (현재 width에 따라 비율 유지)
|
||||
html.css('height', (html.width() * ratio) + 'px');
|
||||
|
||||
// ✅ 창 크기 변경 시 높이 자동 조정
|
||||
function updateHeight() {
|
||||
var currentWidth = html.width();
|
||||
var originalWidth = parseFloat(html.attr('data-original-width')) || currentWidth;
|
||||
var originalHeight = parseFloat(html.attr('data-original-height')) || (currentWidth * ratio);
|
||||
var originalRatio = parseFloat(html.attr('data-ratio')) || (originalHeight / originalWidth);
|
||||
|
||||
html.css('height', (currentWidth * originalRatio) + 'px');
|
||||
}
|
||||
$(window).on('resize', updateHeight);
|
||||
|
||||
// ✅ 크기 조절 기능 적용 (비율 유지)
|
||||
makeImageResizableWithObserver(html);
|
||||
});
|
||||
|
||||
html.append(img);
|
||||
html.append('<button type="button" class="delete-preview"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.9998 13.414L17.6568 19.071C17.8454 19.2532 18.098 19.3539 18.3602 19.3517C18.6224 19.3494 18.8732 19.2442 19.0586 19.0588C19.2441 18.8734 19.3492 18.6226 19.3515 18.3604C19.3538 18.0982 19.253 17.8456 19.0708 17.657L13.4138 12L19.0708 6.343C19.253 6.15439 19.3538 5.90179 19.3515 5.6396C19.3492 5.3774 19.2441 5.12659 19.0586 4.94118C18.8732 4.75577 18.6224 4.6506 18.3602 4.64832C18.098 4.64604 17.8454 4.74684 17.6568 4.929L11.9998 10.586L6.34282 4.929C6.15337 4.75134 5.90224 4.65436 5.64255 4.65858C5.38287 4.6628 5.13502 4.76788 4.95143 4.95159C4.76785 5.1353 4.66294 5.38323 4.65891 5.64292C4.65488 5.9026 4.75203 6.15367 4.92982 6.343L10.5858 12L4.92882 17.657C4.83331 17.7492 4.75713 17.8596 4.70472 17.9816C4.65231 18.1036 4.62473 18.2348 4.62357 18.3676C4.62242 18.5004 4.64772 18.6321 4.698 18.755C4.74828 18.8778 4.82254 18.9895 4.91643 19.0834C5.01032 19.1773 5.12197 19.2515 5.24487 19.3018C5.36777 19.3521 5.49944 19.3774 5.63222 19.3762C5.765 19.3751 5.89622 19.3475 6.01823 19.2951C6.14023 19.2427 6.25058 19.1665 6.34282 19.071L11.9998 13.414Z" fill="#09244B"/></svg></button>');
|
||||
|
||||
wrapper.append(html);
|
||||
insertMediaAfterUrl(wrapper, range);
|
||||
}
|
||||
|
||||
|
||||
$('#editor').on('click', '.delete-preview', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // 부모로의 이벤트 버블링 방지
|
||||
$(this).closest('.url-preview-img, .url-preview-meta, .url-preview-video').remove();
|
||||
$('#image-toolbar').fadeOut(0);
|
||||
selectedImage = null;
|
||||
});
|
||||
|
||||
82
plugin/editor/rb.editor/js/preview.js
Normal file
82
plugin/editor/rb.editor/js/preview.js
Normal file
@ -0,0 +1,82 @@
|
||||
$(document).ready(function () {
|
||||
// 프리뷰 버튼 클릭 이벤트
|
||||
$('#preview-btn').click(function () {
|
||||
const editorContent = $('#editor').html();
|
||||
|
||||
// #editor의 인라인 스타일 가져오기
|
||||
const editorInlineStyle = document.getElementById('editor').getAttribute('style') || '';
|
||||
|
||||
// #editor의 현재 너비 가져오기
|
||||
let editorWidth = $('#editor-container').width() ||
|
||||
document.getElementById('editor-container').getBoundingClientRect().width;
|
||||
|
||||
// 최소 너비 제한
|
||||
const minWidth = 400;
|
||||
const popupWidth = Math.max(editorWidth, minWidth);
|
||||
|
||||
// 현재 적용된 폰트 CSS 파일 가져오기
|
||||
let fontHref = $('#font-stylesheet').attr('href') || '';
|
||||
|
||||
// 팝업창 열기
|
||||
const popupWindow = window.open('', 'previewWindow', `width=${popupWidth},height=600,resizable=yes`);
|
||||
|
||||
// 팝업에 HTML 작성 (jQuery 포함 및 adjustOutputResizable 스크립트 추가)
|
||||
popupWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>미리보기</title>
|
||||
<link rel="stylesheet" href="css/preview.css">
|
||||
${fontHref ? `<link rel="stylesheet" href="${fontHref}">` : ''}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="rb_editor_preview" class="rb_editor_data" style="${editorInlineStyle}">
|
||||
${editorContent}
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const resizableDivs = document.querySelectorAll("div.resizable");
|
||||
|
||||
// 각 요소의 원래 비율을 data 속성에서 계산합니다.
|
||||
resizableDivs.forEach(div => {
|
||||
const originalWidth = div.getAttribute('data-original-width');
|
||||
const originalHeight = div.getAttribute('data-original-height');
|
||||
if (originalWidth && originalHeight) {
|
||||
div.dataset.ratio = originalHeight / originalWidth;
|
||||
} else {
|
||||
// 만약 data 속성이 없다면 현재 크기로 비율 계산 (이 경우는 새로고침 시 문제가 발생할 수 있음)
|
||||
const currentWidth = div.offsetWidth;
|
||||
const currentHeight = div.offsetHeight;
|
||||
if (currentWidth) {
|
||||
div.dataset.ratio = currentHeight / currentWidth;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function updateHeights() {
|
||||
resizableDivs.forEach(div => {
|
||||
const ratio = parseFloat(div.dataset.ratio);
|
||||
if (!isNaN(ratio)) {
|
||||
div.style.height = (div.offsetWidth * ratio) + "px";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 페이지 로드 시와 창 크기 변경 시에 높이 업데이트
|
||||
updateHeights();
|
||||
window.addEventListener("resize", updateHeights);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
popupWindow.document.close();
|
||||
|
||||
// 팝업 크기 조정
|
||||
popupWindow.resizeTo(popupWidth + 20, 620);
|
||||
});
|
||||
});
|
||||
348
plugin/editor/rb.editor/js/save.js
Normal file
348
plugin/editor/rb.editor/js/save.js
Normal file
@ -0,0 +1,348 @@
|
||||
function removeElementsTemporarily(selectors, callback) {
|
||||
const elements = document.querySelectorAll(selectors);
|
||||
const originalStyles = [];
|
||||
|
||||
elements.forEach(el => {
|
||||
originalStyles.push(el.style.display);
|
||||
el.style.display = "none";
|
||||
});
|
||||
|
||||
return callback().finally(() => {
|
||||
elements.forEach((el, index) => {
|
||||
el.style.display = originalStyles[index];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addMarginToCanvas(canvas, marginSize = 50) {
|
||||
const newCanvas = document.createElement("canvas");
|
||||
const ctx = newCanvas.getContext("2d");
|
||||
|
||||
newCanvas.width = canvas.width + marginSize * 2;
|
||||
newCanvas.height = canvas.height + marginSize * 2;
|
||||
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillRect(0, 0, newCanvas.width, newCanvas.height);
|
||||
ctx.drawImage(canvas, marginSize, marginSize);
|
||||
|
||||
return newCanvas;
|
||||
}
|
||||
|
||||
function captureEditor() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const editor = document.getElementById("editor");
|
||||
|
||||
if (!editor) {
|
||||
console.error("Error: `#editor` 요소를 찾을 수 없음");
|
||||
return reject("Missing `#editor` element");
|
||||
}
|
||||
|
||||
const resizableElements = document.querySelectorAll(".resizable");
|
||||
|
||||
// `.selected` 클래스 제거
|
||||
resizableElements.forEach(resizable => resizable.classList.remove("selected"));
|
||||
|
||||
return html2canvas(editor, {
|
||||
useCORS: true,
|
||||
scale: window.devicePixelRatio * 3,
|
||||
allowTaint: true,
|
||||
backgroundColor: null
|
||||
}).then(editorCanvas => {
|
||||
// `.resizable`이 하나도 없으면 `#editor`만 저장
|
||||
if (resizableElements.length === 0) {
|
||||
resolve(editorCanvas);
|
||||
return;
|
||||
}
|
||||
|
||||
// `.resizable`이 있을 경우 개별적으로 필터 적용
|
||||
const newCanvas = document.createElement("canvas");
|
||||
const ctx = newCanvas.getContext("2d");
|
||||
|
||||
newCanvas.width = editorCanvas.width;
|
||||
newCanvas.height = editorCanvas.height;
|
||||
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillRect(0, 0, newCanvas.width, newCanvas.height);
|
||||
ctx.drawImage(editorCanvas, 0, 0);
|
||||
|
||||
const imagePromises = [];
|
||||
|
||||
resizableElements.forEach(resizable => {
|
||||
const img = resizable.querySelector("img");
|
||||
if (!img) return;
|
||||
|
||||
const imgObj = new Image();
|
||||
imgObj.crossOrigin = "anonymous";
|
||||
imgObj.src = img.src;
|
||||
|
||||
const imgStyle = getComputedStyle(img);
|
||||
const resizableRect = resizable.getBoundingClientRect();
|
||||
const editorRect = editor.getBoundingClientRect();
|
||||
|
||||
const imgLeft = resizableRect.left - editorRect.left;
|
||||
const imgTop = resizableRect.top - editorRect.top;
|
||||
const resizableWidth = resizable.offsetWidth;
|
||||
const resizableHeight = resizable.offsetHeight;
|
||||
|
||||
const borderRadius = parseFloat(imgStyle.borderRadius) * 3;
|
||||
const borderWidth = parseFloat(imgStyle.borderWidth) * 3;
|
||||
const borderColor = imgStyle.borderColor;
|
||||
|
||||
const boxShadow = imgStyle.boxShadow.match(/(-?\d+px)/g);
|
||||
const shadowOffsetX = boxShadow ? parseFloat(boxShadow[0]) * 3 : 0;
|
||||
const shadowOffsetY = boxShadow ? parseFloat(boxShadow[1]) * 3 : 0;
|
||||
const shadowBlur = boxShadow ? parseFloat(boxShadow[2]) * 3 : 0;
|
||||
const shadowColor = imgStyle.boxShadow.match(/rgba?\([\d,.\s]+\)/);
|
||||
|
||||
const promise = new Promise((imgResolve) => {
|
||||
imgObj.onload = function () {
|
||||
ctx.save();
|
||||
|
||||
if (shadowColor) {
|
||||
ctx.shadowColor = shadowColor[0];
|
||||
ctx.shadowBlur = shadowBlur;
|
||||
ctx.shadowOffsetX = shadowOffsetX;
|
||||
ctx.shadowOffsetY = shadowOffsetY;
|
||||
}
|
||||
|
||||
ctx.filter = imgStyle.filter;
|
||||
ctx.globalAlpha = imgStyle.opacity;
|
||||
|
||||
const imgRatio = imgObj.width / imgObj.height;
|
||||
const canvasRatio = resizableWidth / resizableHeight;
|
||||
let sx, sy, sWidth, sHeight;
|
||||
|
||||
if (imgRatio > canvasRatio) {
|
||||
sHeight = imgObj.height;
|
||||
sWidth = sHeight * canvasRatio;
|
||||
sx = (imgObj.width - sWidth) / 2;
|
||||
sy = 0;
|
||||
} else {
|
||||
sWidth = imgObj.width;
|
||||
sHeight = sWidth / canvasRatio;
|
||||
sx = 0;
|
||||
sy = (imgObj.height - sHeight) / 2;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(imgLeft * 3, imgTop * 3, resizableWidth * 3, resizableHeight * 3, borderRadius);
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
ctx.drawImage(imgObj, sx, sy, sWidth, sHeight, imgLeft * 3, imgTop * 3, resizableWidth * 3, resizableHeight * 3);
|
||||
|
||||
if (borderWidth > 0) {
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.lineWidth = borderWidth;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
imgResolve();
|
||||
};
|
||||
|
||||
imgObj.onerror = function () {
|
||||
console.error("Error: 이미지 로드 실패");
|
||||
imgResolve();
|
||||
};
|
||||
});
|
||||
|
||||
imagePromises.push(promise);
|
||||
});
|
||||
|
||||
Promise.all(imagePromises).then(() => {
|
||||
resolve(newCanvas);
|
||||
});
|
||||
}).catch(error => {
|
||||
console.error("html2canvas 캡처 오류:", error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* 고도화 후 오픈예정
|
||||
document.getElementById("save-pdf-btn").addEventListener("click", function () {
|
||||
removeElementsTemporarily(".resize-handle, .delete-preview, .rb-video-container", () => {
|
||||
return captureEditor().then(canvas => {
|
||||
const finalCanvas = addMarginToCanvas(canvas, 50);
|
||||
const imgData = finalCanvas.toDataURL("image/png");
|
||||
const {
|
||||
jsPDF
|
||||
} = window.jspdf;
|
||||
const pdf = new jsPDF("p", "mm", "a4");
|
||||
|
||||
const pdfWidth = pdf.internal.pageSize.getWidth(); // A4 너비 (mm)
|
||||
const pdfHeight = pdf.internal.pageSize.getHeight(); // A4 높이 (mm)
|
||||
|
||||
const canvasWidth = finalCanvas.width;
|
||||
const canvasHeight = finalCanvas.height;
|
||||
|
||||
// PDF에 맞는 비율 계산
|
||||
const scaleFactor = pdfWidth / canvasWidth;
|
||||
const imgHeight = canvasHeight * scaleFactor;
|
||||
|
||||
let positionY = 0;
|
||||
|
||||
while (positionY < imgHeight) {
|
||||
pdf.addImage(imgData, "PNG", 0, -positionY, pdfWidth, imgHeight, "FAST");
|
||||
positionY += pdfHeight; // 한 페이지 크기만큼 이동
|
||||
|
||||
if (positionY < imgHeight) {
|
||||
pdf.addPage();
|
||||
}
|
||||
}
|
||||
|
||||
pdf.save("editor.pdf");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
document.getElementById("save-image-btn").addEventListener("click", function () {
|
||||
removeElementsTemporarily(".resize-handle, .delete-preview, .rb-video-container", () => {
|
||||
return captureEditor().then(canvas => {
|
||||
const finalCanvas = addMarginToCanvas(canvas, 50);
|
||||
const link = document.createElement("a");
|
||||
link.download = "editor.png";
|
||||
link.href = finalCanvas.toDataURL("image/png");
|
||||
link.click();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("save-print-btn").addEventListener("click", function () {
|
||||
removeElementsTemporarily(".resize-handle, .delete-preview, .rb-video-container", () => {
|
||||
return captureEditor().then(canvas => {
|
||||
const finalCanvas = addMarginToCanvas(canvas, 50);
|
||||
const imageData = finalCanvas.toDataURL("image/png");
|
||||
|
||||
const printWindow = window.open("", "_blank");
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>인쇄</title>
|
||||
<style>
|
||||
@media print {
|
||||
body { margin: 0; padding: 0; display: flex; justify-content: center; align-items: center; }
|
||||
img { display: block; width: 100%; height: auto; page-break-after: always; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="${imageData}" onload="window.print(); setTimeout(() => { window.close(); }, 500);">
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
*/
|
||||
window.addEventListener("message", function (event) {
|
||||
if (event.data.type === "rbeditor-submit") {
|
||||
const editorContent = document.getElementById("editor").innerHTML;
|
||||
const iframe = window.frameElement;
|
||||
const editorId = iframe.getAttribute("data-editor-id");
|
||||
|
||||
window.parent.postMessage({
|
||||
type: "rbeditor-content",
|
||||
content: editorContent,
|
||||
editorId: editorId
|
||||
}, "*");
|
||||
}
|
||||
|
||||
if (event.data.type === "rbeditor-set-content") {
|
||||
const editorId = event.data.editorId; // 부모 창에서 전달받은 editorId
|
||||
let content = event.data.content;
|
||||
|
||||
if (content) {
|
||||
// 1. 엔티티를 디코딩하여 원래 HTML로 변환
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = content; // 자동 디코딩 수행
|
||||
|
||||
// 2. 에디터에 변환된 HTML 삽입
|
||||
const editor = document.getElementById("editor");
|
||||
if (editor) {
|
||||
editor.innerHTML = tempDiv.textContent || tempDiv.innerText;
|
||||
} else {
|
||||
//console.error(`Editor with ID ${editorId} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 자동저장 전송 */
|
||||
if (event.data.type === "rbeditor-insert-content") {
|
||||
const content = event.data.content;
|
||||
|
||||
// HTML 엔티티 디코딩 (태그 및 스타일 유지)
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = content;
|
||||
const decodedContent = tempDiv.innerHTML;
|
||||
|
||||
// 강제로 `#regular-mode-btn` 클릭 효과 실행
|
||||
function triggerRegularMode() {
|
||||
var regularModeBtn = document.getElementById("regular-mode-btn");
|
||||
|
||||
if (regularModeBtn) {
|
||||
//console.log("#regular-mode-btn 클릭 트리거 실행");
|
||||
regularModeBtn.click(); // 클릭 효과 발생
|
||||
} else {
|
||||
//console.error("#regular-mode-btn을 찾을 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// RB 에디터에 내용 삽입
|
||||
function insertContent() {
|
||||
var editor = document.getElementById("editor");
|
||||
|
||||
if (!editor) {
|
||||
console.error("에디터를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
editor.innerHTML = decodedContent; // 기존 내용 교체
|
||||
editor.focus(); // 포커스 유지
|
||||
//console.log("에디터 내용이 정상적으로 업데이트됨");
|
||||
}
|
||||
|
||||
// `#regular-mode-btn` 클릭 효과 실행 후 내용 삽입
|
||||
triggerRegularMode();
|
||||
setTimeout(insertContent, 100); // 클릭 효과 후 약간의 지연 적용
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (event.data.type === "rbeditor-get-content") {
|
||||
//console.log("autosave 요청 수신"); // 로그 추가
|
||||
const editorContent = document.getElementById("editor").innerHTML;
|
||||
|
||||
// 부모 창으로 에디터 내용 전송
|
||||
event.source.postMessage({
|
||||
type: "rbeditor-content",
|
||||
content: editorContent
|
||||
}, "*");
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/* 고도화 후 오픈예정
|
||||
document.getElementById("btn_rb_autosave").addEventListener("click", function () {
|
||||
//console.log("아이프레임에서 부모 창으로 autosave-trigger 요청 전송");
|
||||
|
||||
// 부모 창으로 자동 저장 실행 요청
|
||||
window.parent.postMessage({
|
||||
type: "autosave-trigger"
|
||||
}, "*");
|
||||
});
|
||||
|
||||
document.getElementById("btn_tb_autosave_popup").addEventListener("click", function () {
|
||||
//console.log("아이프레임에서 부모 창으로 autosave 요청 전송");
|
||||
|
||||
// 부모 창으로 메시지 전송
|
||||
window.parent.postMessage({
|
||||
type: "trigger-autosave-popup"
|
||||
}, "*");
|
||||
});
|
||||
*/
|
||||
32
plugin/editor/rb.editor/js/skin.js
Normal file
32
plugin/editor/rb.editor/js/skin.js
Normal file
@ -0,0 +1,32 @@
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const resizableDivs = document.querySelectorAll("div.resizable");
|
||||
|
||||
// 각 요소의 원래 비율을 data 속성에서 계산합니다.
|
||||
resizableDivs.forEach(div => {
|
||||
const originalWidth = div.getAttribute('data-original-width');
|
||||
const originalHeight = div.getAttribute('data-original-height');
|
||||
if (originalWidth && originalHeight) {
|
||||
div.dataset.ratio = originalHeight / originalWidth;
|
||||
} else {
|
||||
// 만약 data 속성이 없다면 현재 크기로 비율 계산 (이 경우는 새로고침 시 문제가 발생할 수 있음)
|
||||
const currentWidth = div.offsetWidth;
|
||||
const currentHeight = div.offsetHeight;
|
||||
if (currentWidth) {
|
||||
div.dataset.ratio = currentHeight / currentWidth;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function updateHeights() {
|
||||
resizableDivs.forEach(div => {
|
||||
const ratio = parseFloat(div.dataset.ratio);
|
||||
if (!isNaN(ratio)) {
|
||||
div.style.height = (div.offsetWidth * ratio) + "px";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 페이지 로드 시와 창 크기 변경 시에 높이 업데이트
|
||||
updateHeights();
|
||||
window.addEventListener("resize", updateHeights);
|
||||
});
|
||||
901
plugin/editor/rb.editor/js/table.js
Normal file
901
plugin/editor/rb.editor/js/table.js
Normal file
@ -0,0 +1,901 @@
|
||||
//#region 셀 선택 및 툴바 표시
|
||||
|
||||
let selectedCells = [];
|
||||
|
||||
// 셀 클릭 이벤트: Ctrl 키와 함께 클릭 시 셀 선택 토글
|
||||
$('#editor').on('click', 'td, th', function (e) {
|
||||
if (e.ctrlKey) {
|
||||
// Ctrl + 클릭: 선택/해제 토글 기능 유지
|
||||
const cell = this;
|
||||
const isSelected = $(cell).hasClass('selected-cell');
|
||||
|
||||
if (isSelected) {
|
||||
// 이미 선택된 셀인 경우 선택 해제
|
||||
$(cell).removeClass('selected-cell');
|
||||
selectedCells = selectedCells.filter(c => c !== cell);
|
||||
} else {
|
||||
// 선택되지 않은 셀인 경우 선택
|
||||
$(cell).addClass('selected-cell');
|
||||
selectedCells.push(cell);
|
||||
}
|
||||
|
||||
if (selectedCells.length > 0) {
|
||||
// 마지막 클릭 위치(e.pageX, e.pageY)를 기준으로 툴바 표시
|
||||
showCellToolbar(e.pageX, e.pageY);
|
||||
} else {
|
||||
$('#cell-toolbar').fadeOut(0); // 선택된 셀이 없으면 툴바 숨김
|
||||
}
|
||||
} else {
|
||||
// Ctrl 없이 클릭 시 모든 선택된 셀 해제
|
||||
$('td, th').removeClass('selected-cell');
|
||||
selectedCells = [];
|
||||
$('#cell-toolbar').fadeOut(0);
|
||||
}
|
||||
});
|
||||
|
||||
// 선택된 셀 강조 및 툴바 위치 계산
|
||||
function showCellToolbar(x, y) {
|
||||
const $cellToolbar = $('#cell-toolbar');
|
||||
const $editorContainer = $('#editor-container');
|
||||
|
||||
$cellToolbar.css({
|
||||
top: y + 'px',
|
||||
left: x + 'px',
|
||||
position: 'absolute'
|
||||
}).fadeIn(0);
|
||||
|
||||
// 화면을 넘어가지 않도록 조정
|
||||
ensureToolbarWithinBounds($cellToolbar, $editorContainer);
|
||||
}
|
||||
|
||||
// 툴바가 컨테이너 내에 있도록 위치 조정하는 함수
|
||||
function ensureToolbarWithinBounds($toolbar, $container) {
|
||||
const toolbarRect = $toolbar[0].getBoundingClientRect();
|
||||
const containerRect = $container[0].getBoundingClientRect();
|
||||
|
||||
let newTop = parseInt($toolbar.css('top'));
|
||||
let newLeft = parseInt($toolbar.css('left'));
|
||||
|
||||
if (toolbarRect.bottom > containerRect.bottom) {
|
||||
newTop -= (toolbarRect.bottom - containerRect.bottom);
|
||||
}
|
||||
if (toolbarRect.right > containerRect.right) {
|
||||
newLeft -= (toolbarRect.right - containerRect.right);
|
||||
}
|
||||
if (toolbarRect.top < containerRect.top) {
|
||||
newTop = containerRect.top;
|
||||
}
|
||||
if (toolbarRect.left < containerRect.left) {
|
||||
newLeft = containerRect.left;
|
||||
}
|
||||
|
||||
$toolbar.css({
|
||||
top: newTop + 'px',
|
||||
left: newLeft + 'px'
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion 셀 선택 및 툴바 표시
|
||||
|
||||
|
||||
//#region 테이블 그리드 매핑 함수
|
||||
|
||||
/**
|
||||
* 테이블을 그리드로 매핑하여 각 셀의 위치를 반환합니다.
|
||||
* @param {jQuery} $table - jQuery 객체로 된 테이블 요소
|
||||
* @returns {Array} grid - 2차원 배열로 매핑된 셀 위치
|
||||
*/
|
||||
function mapTableGrid($table) {
|
||||
const grid = [];
|
||||
const rows = $table.find('tr');
|
||||
|
||||
rows.each(function (rowIndex, row) {
|
||||
if (!grid[rowIndex]) {
|
||||
grid[rowIndex] = [];
|
||||
}
|
||||
let colIndex = 0;
|
||||
$(row).children('td, th').each(function (cellIndex, cell) {
|
||||
// colspan과 rowspan 속성 가져오기
|
||||
const colspan = parseInt($(cell).attr('colspan')) || 1;
|
||||
const rowspan = parseInt($(cell).attr('rowspan')) || 1;
|
||||
|
||||
// 그리드에서 빈 위치 찾기
|
||||
while (grid[rowIndex][colIndex]) {
|
||||
colIndex++;
|
||||
}
|
||||
|
||||
// 현재 셀을 그리드에 매핑
|
||||
for (let i = 0; i < rowspan; i++) {
|
||||
for (let j = 0; j < colspan; j++) {
|
||||
if (!grid[rowIndex + i]) {
|
||||
grid[rowIndex + i] = [];
|
||||
}
|
||||
grid[rowIndex + i][colIndex + j] = cell;
|
||||
}
|
||||
}
|
||||
colIndex += colspan;
|
||||
});
|
||||
});
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
//#endregion 테이블 그리드 매핑 함수
|
||||
|
||||
|
||||
//#region 셀 병합 기능
|
||||
|
||||
// 셀 병합 버튼 클릭 이벤트
|
||||
$('#merge-cells-btn').click(function () {
|
||||
if (selectedCells.length <= 1) {
|
||||
alert('병합할 셀을 2개 이상 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const $table = $(selectedCells[0]).closest('table');
|
||||
const grid = mapTableGrid($table);
|
||||
|
||||
// 선택된 셀들의 행과 열 인덱스 추출
|
||||
const cellPositions = selectedCells.map(cell => {
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
for (let c = 0; c < grid[r].length; c++) {
|
||||
if (grid[r][c] === cell) {
|
||||
return {
|
||||
row: r,
|
||||
col: c
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 최소 및 최대 행, 열 인덱스 계산
|
||||
const rowsIndices = cellPositions.map(pos => pos.row);
|
||||
const colsIndices = cellPositions.map(pos => pos.col);
|
||||
|
||||
const minRow = Math.min(...rowsIndices);
|
||||
const maxRow = Math.max(...rowsIndices);
|
||||
const minCol = Math.min(...colsIndices);
|
||||
const maxCol = Math.max(...colsIndices);
|
||||
|
||||
const rowSpan = maxRow - minRow + 1;
|
||||
const colSpan = maxCol - minCol + 1;
|
||||
|
||||
// 선택된 영역이 완전한 사각형을 형성하는지 검증
|
||||
for (let r = minRow; r <= maxRow; r++) {
|
||||
for (let c = minCol; c <= maxCol; c++) {
|
||||
const cell = grid[r][c];
|
||||
if (!selectedCells.includes(cell)) {
|
||||
alert('선택된 셀들은 병합할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const $cell = $(cell);
|
||||
const existingRowSpan = parseInt($cell.attr('rowspan')) || 1;
|
||||
const existingColSpan = parseInt($cell.attr('colspan')) || 1;
|
||||
|
||||
if (existingRowSpan > 1 || existingColSpan > 1) {
|
||||
alert('이미 병합된 셀은 병합할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 좌상단 셀 선택 및 rowspan, colspan 설정
|
||||
const topLeftCell = grid[minRow][minCol];
|
||||
const $topLeftCell = $(topLeftCell);
|
||||
|
||||
if (rowSpan > 1) {
|
||||
$topLeftCell.attr('rowspan', rowSpan);
|
||||
}
|
||||
if (colSpan > 1) {
|
||||
$topLeftCell.attr('colspan', colSpan);
|
||||
}
|
||||
|
||||
// 나머지 셀들 제거
|
||||
for (let r = minRow; r <= maxRow; r++) {
|
||||
for (let c = minCol; c <= maxCol; c++) {
|
||||
if (r === minRow && c === minCol) continue;
|
||||
const cell = grid[r][c];
|
||||
$(cell).remove();
|
||||
}
|
||||
}
|
||||
|
||||
// 선택 초기화 및 툴바 숨김
|
||||
selectedCells = [];
|
||||
$('td, th').removeClass('selected-cell');
|
||||
$('#cell-toolbar').fadeOut(0);
|
||||
});
|
||||
|
||||
//#endregion 셀 병합 기능
|
||||
|
||||
|
||||
//#region 셀 병합 해제 기능
|
||||
|
||||
$('#unmerge-cells-btn').click(function () {
|
||||
if (selectedCells.length !== 1) {
|
||||
alert('병합 해제는 병합된 셀 하나만 선택해야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const $table = $(selectedCells[0]).closest('table');
|
||||
const grid = mapTableGrid($table);
|
||||
|
||||
// 병합된 셀 정보 가져오기
|
||||
const $mergedCell = $(selectedCells[0]);
|
||||
const rowspan = parseInt($mergedCell.attr('rowspan')) || 1;
|
||||
const colspan = parseInt($mergedCell.attr('colspan')) || 1;
|
||||
|
||||
if (rowspan === 1 && colspan === 1) {
|
||||
alert('이 셀은 병합되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedCellPosition = (() => {
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
for (let c = 0; c < grid[r].length; c++) {
|
||||
if (grid[r][c] === $mergedCell[0]) {
|
||||
return {
|
||||
row: r,
|
||||
col: c
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (!mergedCellPosition) {
|
||||
alert('병합된 셀의 위치를 확인할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
row: startRow,
|
||||
col: startCol
|
||||
} = mergedCellPosition;
|
||||
|
||||
// 병합된 셀의 rowspan 및 colspan에 따라 셀 추가
|
||||
for (let r = startRow; r < startRow + rowspan; r++) {
|
||||
for (let c = startCol; c < startCol + colspan; c++) {
|
||||
if (r === startRow && c === startCol) {
|
||||
// 기존 병합된 셀은 rowspan, colspan 제거
|
||||
$mergedCell.removeAttr('rowspan').removeAttr('colspan');
|
||||
} else {
|
||||
// 나머지 셀 복원
|
||||
const $newCell = $('<td><div><br></div></td>').css({
|
||||
border: '1px solid #ddd',
|
||||
padding: '5px',
|
||||
});
|
||||
|
||||
// 그리드 위치에 따라 셀 삽입
|
||||
const $row = $table.find('tr').eq(r);
|
||||
// 그리드 내 현재 셀의 위치를 찾기 (colSpan을 고려)
|
||||
let insertBefore = null;
|
||||
let currentCol = 0;
|
||||
$row.children('td, th').each(function () {
|
||||
const colspanAttr = parseInt($(this).attr('colspan')) || 1;
|
||||
if (currentCol === c) {
|
||||
insertBefore = this;
|
||||
return false; // 루프 종료
|
||||
}
|
||||
currentCol += colspanAttr;
|
||||
});
|
||||
if (insertBefore) {
|
||||
$(insertBefore).before($newCell);
|
||||
} else {
|
||||
$row.append($newCell);
|
||||
}
|
||||
|
||||
// 그리드 업데이트
|
||||
grid[r][c] = $newCell[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 선택 초기화 및 툴바 숨김
|
||||
selectedCells = [];
|
||||
$('td, th').removeClass('selected-cell');
|
||||
$('#cell-toolbar').fadeOut(0);
|
||||
});
|
||||
|
||||
//#endregion 셀 병합 해제 기능
|
||||
|
||||
|
||||
//#region 셀 배경색 변경
|
||||
|
||||
$('#cell-bg-color-btn').on('click', function () {
|
||||
$('#cell-bg-color-picker').click();
|
||||
});
|
||||
|
||||
$('#cell-bg-color-picker').on('input', function () {
|
||||
const selectedColor = $(this).val();
|
||||
|
||||
// 선택된 셀의 배경 색상 변경
|
||||
$('.selected-cell').each(function () {
|
||||
$(this).css('background-color', selectedColor);
|
||||
$('#cell-bg-color-btn').css('background-color', selectedColor);
|
||||
|
||||
// 선택 후 초기화 (필요 시 주석 해제)
|
||||
// selectedCells = [];
|
||||
// $('td, th').removeClass('selected-cell');
|
||||
});
|
||||
});
|
||||
|
||||
//#endregion 셀 배경색 변경
|
||||
|
||||
|
||||
//#region 문서 외부 클릭 이벤트 처리
|
||||
|
||||
// 외부 클릭 시 초기화
|
||||
$(document).on('click', function (e) {
|
||||
if (!$(e.target).closest('#editor').length && !$(e.target).closest('#toolbar').length) {
|
||||
savedSelection = null;
|
||||
}
|
||||
|
||||
const $grid = $('#table-grid');
|
||||
if (!$(e.target).closest('#table-grid').length && !$(e.target).is('#insert-table-btn')) {
|
||||
$grid.fadeOut(0);
|
||||
}
|
||||
|
||||
// 표 셀 선택 해제
|
||||
if (!$(e.target).closest('td, th, #cell-toolbar').length) {
|
||||
selectedCells = [];
|
||||
$('td, th').removeClass('selected-cell');
|
||||
$('#cell-toolbar').fadeOut(0);
|
||||
}
|
||||
|
||||
const isInsideEditor = $(e.target).closest('#editor').length > 0; // 클릭한 요소가 #editor 내부인지 확인
|
||||
const isResizable = $(e.target).closest('.resizable').length > 0; // 클릭한 요소가 .resizable인지 확인
|
||||
const isToolbar = $(e.target).closest('#image-toolbar').length > 0; // 클릭한 요소가 툴바인지 확인
|
||||
|
||||
if (!isResizable && !isToolbar) {
|
||||
$('.resizable').removeClass('selected'); // 모든 이미지에서 selected 제거
|
||||
$('#image-toolbar').fadeOut(0); // 툴바 숨기기
|
||||
}
|
||||
});
|
||||
|
||||
// #cell-toolbar 내부 클릭 시 이벤트 전파 차단
|
||||
$('#cell-toolbar').on('click', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
//#endregion 문서 외부 클릭 이벤트 처리
|
||||
|
||||
|
||||
//#region 정렬 관련 함수
|
||||
|
||||
function applyAlignmentToSelectedCells(alignment) {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
|
||||
if (range) {
|
||||
const ancestorTable = $(range.commonAncestorContainer).closest('table');
|
||||
|
||||
// 표 내부일 경우
|
||||
if (ancestorTable.length) {
|
||||
// 드래그로 선택된 셀들 가져오기
|
||||
const selectedCells = ancestorTable.find('td, th').filter(function () {
|
||||
const cellRange = document.createRange();
|
||||
cellRange.selectNodeContents(this);
|
||||
|
||||
// 드래그로 선택된 범위와 셀이 겹치는지 확인
|
||||
return (
|
||||
range.compareBoundaryPoints(Range.START_TO_END, cellRange) !== -1 &&
|
||||
range.compareBoundaryPoints(Range.END_TO_START, cellRange) !== 1
|
||||
);
|
||||
});
|
||||
|
||||
// Ctrl + 클릭으로 선택된 셀은 제외
|
||||
const filteredCells = selectedCells.filter(function () {
|
||||
return !$(this).hasClass('selected-cell'); // 'selected-cell' 클래스 제외
|
||||
});
|
||||
|
||||
// 선택된 셀만 정렬 적용
|
||||
filteredCells.each(function () {
|
||||
$(this).css('text-align', alignment);
|
||||
});
|
||||
|
||||
return true; // 표 정렬 적용 완료
|
||||
}
|
||||
}
|
||||
|
||||
return false; // 표 정렬 미적용
|
||||
}
|
||||
|
||||
function applyAlignmentToSelectedCellsOrEditor(alignment) {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
|
||||
if (range) {
|
||||
const commonAncestor = range.commonAncestorContainer;
|
||||
|
||||
// 선택된 범위 내의 모든 셀(td, th)을 가져옴
|
||||
const selectedCells = $(commonAncestor)
|
||||
.closest('table')
|
||||
.find('td, th')
|
||||
.filter(function () {
|
||||
const cellRange = document.createRange();
|
||||
cellRange.selectNodeContents(this);
|
||||
|
||||
// 셀이 선택된 범위와 겹치는지 확인
|
||||
return (
|
||||
range.compareBoundaryPoints(Range.START_TO_END, cellRange) !== -1 &&
|
||||
range.compareBoundaryPoints(Range.END_TO_START, cellRange) !== 1
|
||||
);
|
||||
});
|
||||
|
||||
if (selectedCells.length > 0) {
|
||||
// 선택된 모든 셀에 정렬 적용
|
||||
selectedCells.each(function () {
|
||||
$(this).css('text-align', alignment);
|
||||
});
|
||||
} else {
|
||||
// 선택된 범위가 없거나 표 외부인 경우 전체 에디터에 정렬 적용
|
||||
$('#editor').children(':not(table)').css('text-align', alignment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyAlignmentToTableCells(range, alignment) {
|
||||
const selectedCells = getSelectedCellsInRange(range);
|
||||
|
||||
if (selectedCells.length > 0) {
|
||||
selectedCells.each(function () {
|
||||
$(this).css('text-align', alignment);
|
||||
});
|
||||
return true; // 표 내부 정렬 적용 완료
|
||||
}
|
||||
return false; // 표 내부 정렬 없음
|
||||
}
|
||||
|
||||
// 범위 내에서 선택된 셀(td, th) 가져오기
|
||||
function getSelectedCellsInRange(range) {
|
||||
const selectedCells = [];
|
||||
const ancestor = range.commonAncestorContainer;
|
||||
|
||||
// 범위가 표 내부인지 확인
|
||||
const table = $(ancestor).closest('table');
|
||||
if (table.length) {
|
||||
const startContainer = range.startContainer;
|
||||
const endContainer = range.endContainer;
|
||||
|
||||
const startCell = $(startContainer).closest('td, th')[0];
|
||||
const endCell = $(endContainer).closest('td, th')[0];
|
||||
|
||||
if (startCell && endCell) {
|
||||
let selecting = false;
|
||||
|
||||
table.find('tr').each(function () {
|
||||
$(this).children('td, th').each(function () {
|
||||
if (this === startCell) {
|
||||
selecting = true; // 선택 시작
|
||||
selectedCells.push(this);
|
||||
} else if (this === endCell) {
|
||||
selectedCells.push(this); // 선택 끝
|
||||
selecting = false; // 선택 종료
|
||||
} else if (selecting) {
|
||||
selectedCells.push(this); // 선택 중간 셀
|
||||
}
|
||||
});
|
||||
|
||||
if (!selecting && selectedCells.length > 0) {
|
||||
return false; // 선택 종료 시 루프 탈출
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return $(selectedCells); // jQuery 객체로 반환
|
||||
}
|
||||
|
||||
//#endregion 정렬 관련 함수
|
||||
|
||||
|
||||
//#region 테이블 삽입 기능
|
||||
|
||||
/**
|
||||
* 주어진 행과 열 수로 테이블 생성 및 에디터에 삽입
|
||||
* @param {number} rows - 테이블 행 수
|
||||
* @param {number} cols - 테이블 열 수
|
||||
*/
|
||||
function insertTable(rows, cols) {
|
||||
if (savedSelection) {
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(savedSelection); // 이전 커서 위치 복원
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||
|
||||
if (range) {
|
||||
let container = range.commonAncestorContainer;
|
||||
if (container.nodeType === Node.TEXT_NODE) container = container.parentElement;
|
||||
|
||||
// ✅ 테이블 내부(td, th)인지 확인 후 생성 제한
|
||||
if ($(container).closest('td, th').length) {
|
||||
alert("테이블 내부에서는 테이블을 생성할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 테이블을 정확한 행*열로 생성
|
||||
let tableHTML = `
|
||||
<div class="resizable-table" style="width: 100%; position: relative;">
|
||||
<div class="rb_editor_table_wrap">
|
||||
<div class="rb_editor_table_wrap_inner">
|
||||
<table style="width: 100%; border-collapse: collapse; border: 1px solid #ddd;">
|
||||
|
||||
<tbody>
|
||||
${Array(rows).fill(`<tr>${Array(cols).fill('<td style="border: 1px solid #ddd; padding: 10px; height:40px;"><div><br></div></td>').join('')}</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resizable-table-handle" style="width: 5px; height: 100%; position: absolute; right: 0; top: 0; cursor: ew-resize; background: rgba(170, 32, 255, 0.3);"></div>
|
||||
</div>
|
||||
<div><br></div>
|
||||
`;
|
||||
|
||||
const $table = $(tableHTML);
|
||||
const $editor = $('#editor');
|
||||
|
||||
if (range) {
|
||||
let container = range.commonAncestorContainer;
|
||||
if (container.nodeType === Node.TEXT_NODE) container = container.parentElement;
|
||||
|
||||
if ($(container).closest('#editor').length) {
|
||||
range.deleteContents();
|
||||
range.insertNode($table[0]);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else {
|
||||
$editor.append($table);
|
||||
}
|
||||
} else {
|
||||
$editor.append($table);
|
||||
}
|
||||
|
||||
enableResizableTable($table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 테이블 크기 조정 기능 활성화
|
||||
* @param {HTMLElement} table - 크기 조정할 테이블 요소
|
||||
*/
|
||||
function enableResizableTable(table) {
|
||||
// 테이블이 jQuery 객체라면 DOM 요소로 변환
|
||||
if (table instanceof jQuery) {
|
||||
table = table.get(0);
|
||||
}
|
||||
|
||||
// 테이블이 NodeList(배열)라면 첫 번째 요소 선택
|
||||
if (NodeList.prototype.isPrototypeOf(table) || Array.isArray(table)) {
|
||||
table = table[0];
|
||||
}
|
||||
|
||||
// 유효한 DOM 요소인지 확인
|
||||
if (!(table instanceof HTMLElement)) {
|
||||
//console.error("⚠️ enableResizableTable: 유효한 테이블 요소가 아닙니다.", table);
|
||||
return;
|
||||
}
|
||||
|
||||
let handle = table.querySelector('.resizable-table-handle');
|
||||
|
||||
// ✅ 핸들이 없으면 자동 추가
|
||||
if (!handle) {
|
||||
//console.warn('resizable-table-handle 요소가 없습니다. 자동 추가합니다.');
|
||||
handle = document.createElement("div");
|
||||
handle.classList.add("resizable-table-handle");
|
||||
table.appendChild(handle);
|
||||
}
|
||||
|
||||
let isResizing = false;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
|
||||
function startResize(event) {
|
||||
isResizing = true;
|
||||
startX = event.type.includes('touch') ? event.touches[0].clientX : event.clientX;
|
||||
startWidth = table.offsetWidth;
|
||||
|
||||
document.addEventListener('mousemove', doResize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
document.addEventListener('touchmove', doResize, { passive: false });
|
||||
document.addEventListener('touchend', stopResize, { passive: false });
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function doResize(event) {
|
||||
if (!isResizing) return;
|
||||
|
||||
const moveX = event.type.includes('touch') ? event.touches[0].clientX : event.clientX;
|
||||
const newWidth = startWidth + (moveX - startX);
|
||||
|
||||
if (newWidth > 100) { // 최소 너비 제한
|
||||
table.style.width = `${newWidth}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
isResizing = false;
|
||||
document.removeEventListener('mousemove', doResize);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
document.removeEventListener('touchmove', doResize);
|
||||
document.removeEventListener('touchend', stopResize);
|
||||
}
|
||||
|
||||
// 기존 이벤트 제거 후 다시 바인딩
|
||||
handle.removeEventListener('mousedown', startResize);
|
||||
handle.removeEventListener('touchstart', startResize);
|
||||
handle.addEventListener('mousedown', startResize);
|
||||
handle.addEventListener('touchstart', startResize, { passive: false });
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 에디터 내 모든 테이블을 감싸고 핸들 추가
|
||||
*/
|
||||
function wrapTablesWithEditorWrap() {
|
||||
$('#editor table').each(function () {
|
||||
const $table = $(this);
|
||||
|
||||
if ($table.closest('.resizable-table').length) return; // 이미 감싸져 있으면 패스
|
||||
|
||||
// 테이블 스타일 초기화 및 새로운 스타일 적용
|
||||
$table.removeAttr('style').css({
|
||||
'width': '100%',
|
||||
'border-collapse': 'collapse',
|
||||
'border': '1px solid rgb(221, 221, 221)',
|
||||
'table-layout': 'fixed'
|
||||
});
|
||||
|
||||
// 테이블 내부의 td 및 th 스타일 초기화 및 새로운 스타일 적용
|
||||
$table.find('td, th').each(function () {
|
||||
$(this).removeAttr('style').css({
|
||||
'border': '1px solid rgb(221, 221, 221)',
|
||||
'padding': '10px',
|
||||
'height': '40px',
|
||||
'position': 'relative'
|
||||
});
|
||||
});
|
||||
|
||||
const $wrapper = $('<div class="resizable-table" style="width: 100%; position: relative;"></div>');
|
||||
//const $handle = $('<div class="resizable-table-handle" style="width: 5px; height: 100%; position: absolute; right: 0; top: 0; cursor: ew-resize; background: rgba(170, 32, 255, 0.3)"></div>');
|
||||
|
||||
// 기존 래퍼 유지
|
||||
const $wrapInner = $('<div class="rb_editor_table_wrap"><div class="rb_editor_table_wrap_inner"></div></div>');
|
||||
|
||||
// 테이블을 감싸기만 함 (이동 X, 제거 X)
|
||||
$table.wrap($wrapInner);
|
||||
|
||||
// 최종 래퍼 구성
|
||||
$table.parent().parent().wrap($wrapper);
|
||||
//$table.parent().parent().parent().append($handle);
|
||||
|
||||
enableResizableTable($table.closest('.resizable-table'));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 주어진 테이블의 셀 크기 조정 기능 활성화
|
||||
* @param {jQuery} $table - 크기 조정할 테이블 요소
|
||||
*/
|
||||
function enableResizableTableCells($table) {
|
||||
const resizeMargin = 10; // 커서 반경 확대
|
||||
let isResizing = false;
|
||||
let resizingCell = null;
|
||||
const passiveOptions = { passive: true };
|
||||
|
||||
$table.css({
|
||||
"table-layout": "fixed", // 테이블 레이아웃 고정
|
||||
"width": "100%", // 테이블 전체 폭 100%
|
||||
});
|
||||
|
||||
$table.find('td, th').each(function () {
|
||||
const $cell = $(this);
|
||||
$cell.css({ position: 'relative' });
|
||||
|
||||
// ✅ 마지막 열인지 확인하여 크기 조정 방지
|
||||
const isLastColumn = $cell.is(':last-child');
|
||||
if (isLastColumn) return; // 마지막 열은 크기 조정 X
|
||||
|
||||
function handleMove(event) {
|
||||
const isTouch = event.type.includes('touch');
|
||||
const offsetX = isTouch ? event.touches[0].clientX - $cell.offset().left : event.offsetX;
|
||||
const offsetY = isTouch ? event.touches[0].clientY - $cell.offset().top : event.offsetY;
|
||||
const cellWidth = $cell.outerWidth();
|
||||
const cellHeight = $cell.outerHeight();
|
||||
const $row = $cell.closest('tr');
|
||||
|
||||
$table.find('tr').removeClass('resize-row-highlight');
|
||||
$cell.removeClass('resize-right-highlight resize-bottom-highlight');
|
||||
|
||||
// ✅ 마지막 열은 커서 변경 X
|
||||
if (isLastColumn) {
|
||||
$cell.css('cursor', 'text');
|
||||
return;
|
||||
}
|
||||
|
||||
if (offsetX > cellWidth - resizeMargin && offsetY > cellHeight - resizeMargin) {
|
||||
$cell.addClass('resize-right-highlight resize-bottom-highlight');
|
||||
$row.addClass('resize-row-highlight');
|
||||
$cell.css('cursor', 'se-resize');
|
||||
} else if (offsetX > cellWidth - resizeMargin) {
|
||||
$cell.addClass('resize-right-highlight');
|
||||
$cell.css('cursor', 'ew-resize');
|
||||
} else if (offsetY > cellHeight - resizeMargin) {
|
||||
$row.addClass('resize-row-highlight');
|
||||
$cell.css('cursor', 'ns-resize');
|
||||
} else {
|
||||
$cell.css('cursor', 'text');
|
||||
}
|
||||
}
|
||||
|
||||
$cell.on('mousemove', handleMove);
|
||||
$cell.get(0).addEventListener('touchmove', handleMove, passiveOptions);
|
||||
|
||||
function handleStart(event) {
|
||||
const isTouch = event.type.includes('touch');
|
||||
const touch = isTouch ? event.touches[0] : null;
|
||||
const startX = isTouch ? touch.clientX : event.pageX;
|
||||
const startY = isTouch ? touch.clientY : event.pageY;
|
||||
const cellWidth = $cell.outerWidth();
|
||||
const cellHeight = $cell.outerHeight();
|
||||
const $table = $cell.closest('table');
|
||||
|
||||
if (isLastColumn) return; // ✅ 마지막 열이면 크기 조정 시작 X
|
||||
|
||||
const isResizeArea = (
|
||||
startX > $cell.offset().left + cellWidth - resizeMargin ||
|
||||
startY > $cell.offset().top + cellHeight - resizeMargin
|
||||
);
|
||||
|
||||
if (!isResizeArea) return;
|
||||
|
||||
event.preventDefault();
|
||||
isResizing = true;
|
||||
resizingCell = $cell;
|
||||
|
||||
const startWidth = $cell.outerWidth();
|
||||
const startHeight = $cell.outerHeight();
|
||||
const cellIndex = $cell.index();
|
||||
const $row = $cell.closest('tr');
|
||||
const rowIndex = $row.index();
|
||||
|
||||
function doResize(e) {
|
||||
const isTouchMove = e.type.includes('touch');
|
||||
const moveX = isTouchMove ? e.touches[0].clientX : e.pageX;
|
||||
const moveY = isTouchMove ? e.touches[0].clientY : e.pageY;
|
||||
|
||||
if (isResizing && resizingCell) {
|
||||
if (startX > $cell.offset().left + cellWidth - resizeMargin) {
|
||||
const newWidth = startWidth + (moveX - startX);
|
||||
if (newWidth > 30) {
|
||||
$table.find('tr').each(function () {
|
||||
$(this).children().eq(cellIndex).css('width', `${newWidth}px`);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (startY > $cell.offset().top + cellHeight - resizeMargin) {
|
||||
const newHeight = startHeight + (moveY - startY);
|
||||
if (newHeight > 20) {
|
||||
$table.find('tr').eq(rowIndex).children().css('height', `${newHeight}px`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
isResizing = false;
|
||||
resizingCell = null;
|
||||
$(document).off('mousemove', doResize);
|
||||
$(document).off('mouseup', stopResize);
|
||||
document.removeEventListener('touchmove', doResize, passiveOptions);
|
||||
document.removeEventListener('touchend', stopResize, passiveOptions);
|
||||
}
|
||||
|
||||
$(document).on('mousemove', doResize);
|
||||
$(document).on('mouseup', stopResize);
|
||||
document.addEventListener('touchmove', doResize, passiveOptions);
|
||||
document.addEventListener('touchend', stopResize, passiveOptions);
|
||||
}
|
||||
|
||||
$cell.on('mousedown', handleStart);
|
||||
$cell.get(0).addEventListener('touchstart', handleStart, { passive: false });
|
||||
|
||||
$cell.on('mouseleave touchend', function () {
|
||||
if (!isResizing) {
|
||||
$table.find('tr').removeClass('resize-row-highlight');
|
||||
$cell.removeClass('resize-right-highlight resize-bottom-highlight');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
//#endregion 셀 크기 조정 기능
|
||||
|
||||
|
||||
//#region 테이블 리사이징 초기화 (기존 테이블에 적용)
|
||||
|
||||
function initializeResizableTables() {
|
||||
$('#editor table').each(function () {
|
||||
enableResizableTableCells($(this));
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion 테이블 리사이징 초기화 (기존 테이블에 적용)
|
||||
|
||||
|
||||
//#region MutationObserver 설정
|
||||
|
||||
/**
|
||||
* MutationObserver를 사용하여 새로운 테이블이 추가될 때 자동으로 크기 조정 기능을 적용합니다.
|
||||
*/
|
||||
const tableObserver = new MutationObserver(function (mutationsList) {
|
||||
for (let mutation of mutationsList) {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach(node => {
|
||||
if ($(node).is('table')) {
|
||||
enableResizableTableCells($(node));
|
||||
} else {
|
||||
// 만약 노드가 테이블을 포함하고 있다면
|
||||
$(node).find('table').each(function () {
|
||||
enableResizableTableCells($(this));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// #editor 내부의 변경 사항 관찰
|
||||
tableObserver.observe(document.getElementById('editor'), {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
//#endregion MutationObserver 설정
|
||||
|
||||
|
||||
//#region 이벤트 핸들러 등록
|
||||
|
||||
// 정렬 버튼 이벤트 예시 (Assuming you have buttons for alignment)
|
||||
$('#align-left-btn').click(function () {
|
||||
applyAlignmentToSelectedCells('left');
|
||||
});
|
||||
|
||||
$('#align-center-btn').click(function () {
|
||||
applyAlignmentToSelectedCells('center');
|
||||
});
|
||||
|
||||
$('#align-right-btn').click(function () {
|
||||
applyAlignmentToSelectedCells('right');
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 페이지 로드 시 실행
|
||||
$(document).ready(function () {
|
||||
wrapTablesWithEditorWrap();
|
||||
initializeResizableTables();
|
||||
|
||||
// MutationObserver로 동적 감지
|
||||
const observer = new MutationObserver(() => {
|
||||
wrapTablesWithEditorWrap();
|
||||
});
|
||||
|
||||
observer.observe(document.getElementById('editor'), { childList: true, subtree: true });
|
||||
});
|
||||
237
plugin/editor/rb.editor/js/tag.js
Normal file
237
plugin/editor/rb.editor/js/tag.js
Normal file
@ -0,0 +1,237 @@
|
||||
$(document).ready(function () {
|
||||
let tagPopup = null;
|
||||
let searchTimeout = null;
|
||||
let lastSelection = null;
|
||||
|
||||
$('#editor').on('input', function () {
|
||||
const sel = window.getSelection();
|
||||
if (sel.rangeCount === 0) return;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
|
||||
let charBeforeCursor = '';
|
||||
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
charBeforeCursor = container.nodeValue[offset - 1];
|
||||
} else if (container.nodeType === Node.ELEMENT_NODE && offset > 0) {
|
||||
const nodeBeforeCursor = container.childNodes[offset - 1];
|
||||
if (nodeBeforeCursor.nodeType === Node.TEXT_NODE) {
|
||||
charBeforeCursor = nodeBeforeCursor.nodeValue.slice(-1);
|
||||
}
|
||||
}
|
||||
|
||||
if (charBeforeCursor === '#') {
|
||||
saveSelection();
|
||||
showTagPopup();
|
||||
|
||||
$('#rb-tag-list').empty().append('<div class="loadingOverlay loadingOverlay3"><div class="spinner3"></div></div>');
|
||||
updateTagList('');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 팝업 생성 및 위치 지정
|
||||
function showTagPopup() {
|
||||
if ($('#rb-tag-popup').length === 0) {
|
||||
tagPopup = $('<div id="rb-tag-popup"></div>').hide();
|
||||
|
||||
let d_title = $('<h4 class="font-B">게시물 태그</h4>');
|
||||
let d_chk = $('<div class="rb_tag_chk"><input type="checkbox" id="rb-tag-my"><label for="rb-tag-my">내가 쓴 글</label></div>');
|
||||
let searchInput = $('<input type="text" id="rb-tag-search" placeholder="태그 또는 검색어 입력" autocomplete="off"/>');
|
||||
|
||||
tagPopup.append(d_title, d_chk, searchInput).append('<ul id="rb-tag-list"></ul>');
|
||||
$('body').append(tagPopup);
|
||||
}
|
||||
|
||||
let selection = window.getSelection();
|
||||
let range = selection.getRangeAt(0);
|
||||
let rect = getCaretPosition(range);
|
||||
|
||||
const popupHeight = $('#rb-tag-popup').outerHeight();
|
||||
const popupWidth = $('#rb-tag-popup').outerWidth();
|
||||
const windowWidth = $(window).width();
|
||||
const windowHeight = $(window).height();
|
||||
|
||||
let popupTop = rect.bottom + window.scrollY + 10;
|
||||
let popupLeft = rect.left + window.scrollX;
|
||||
|
||||
if (popupTop + popupHeight > windowHeight) {
|
||||
popupTop = windowHeight - popupHeight - 10;
|
||||
}
|
||||
if (popupLeft + popupWidth > windowWidth) {
|
||||
popupLeft = windowWidth - popupWidth - 10;
|
||||
}
|
||||
if (popupLeft < 0) {
|
||||
popupLeft = 10;
|
||||
}
|
||||
if (popupTop < 0) {
|
||||
popupTop = 10;
|
||||
}
|
||||
|
||||
tagPopup.css({ top: popupTop + 'px', left: popupLeft + 'px' }).fadeIn(100);
|
||||
updateTagList('');
|
||||
}
|
||||
|
||||
// 커서 위치 가져오기
|
||||
function getCaretPosition(range) {
|
||||
let rect = range.getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
let span = document.createElement('span');
|
||||
range.insertNode(span);
|
||||
rect = span.getBoundingClientRect();
|
||||
span.remove();
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
|
||||
// 게시물 목록 업데이트
|
||||
function updateTagList(query) {
|
||||
clearTimeout(searchTimeout);
|
||||
|
||||
let list = $('#rb-tag-list');
|
||||
|
||||
// ✅ 로딩 인디케이터가 없으면 추가
|
||||
if ($('#rb-tag-list .loadingOverlay3').length === 0) {
|
||||
list.append('<div class="loadingOverlay loadingOverlay3"><div class="spinner3"></div></div>');
|
||||
}
|
||||
|
||||
// ✅ `#` 입력 즉시 로딩 UI 보이기
|
||||
$('.loadingOverlay3').show();
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
let isMyPostChecked = $('#rb-tag-my').prop('checked');
|
||||
|
||||
$.ajax({
|
||||
url: g5Config.g5_editor_url + '/plugin/tag/ajax.result.php',
|
||||
type: 'POST',
|
||||
data: { search: query, mypost: isMyPostChecked ? '1' : '0' },
|
||||
dataType: 'json',
|
||||
success: function (data) {
|
||||
list.empty(); // ✅ 기존 리스트 초기화
|
||||
|
||||
if (!query && data.length > 0) {
|
||||
list.append('<li class="rb-tag-item info">엔터 입력시 검색어를 링크로 추가 합니다.</li>');
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
list.append('<li class="rb-tag-item no-result">검색 결과 없음</li>');
|
||||
} else {
|
||||
data.forEach(item => {
|
||||
let listItem = $(`
|
||||
<li class="rb-tag-item" data-url="${item.url}">
|
||||
<dd class='rb_tag_tit font-B cut'>${item.title}</dd>
|
||||
<dd class='rb_tag_date'>${item.wr_name} ${item.wr_datetime}</dd>
|
||||
<dd class='rb_tag_bbs'>${item.bo_subject}</dd>
|
||||
<img src='${item.thumbnail}' alt="${item.title}">
|
||||
</li>
|
||||
`);
|
||||
list.append(listItem);
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
list.empty().append('<li class="rb-tag-item error">검색 중 오류 발생</li>');
|
||||
},
|
||||
complete: function () {
|
||||
// ✅ AJAX 완료 후 로딩 UI 숨김
|
||||
$('.loadingOverlay3').hide();
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
|
||||
// 체크박스 변경 시 업데이트
|
||||
$(document).on('change', '#rb-tag-my', function () {
|
||||
let searchQuery = $('#rb-tag-search').val().trim();
|
||||
updateTagList(searchQuery);
|
||||
});
|
||||
|
||||
// 검색 입력 시 업데이트
|
||||
$(document).on('input', '#rb-tag-search', function () {
|
||||
let searchQuery = $(this).val().trim();
|
||||
updateTagList(searchQuery);
|
||||
});
|
||||
|
||||
// `rb-tag-item` 클릭 시 에디터에 삽입
|
||||
$(document).on('click', '#rb-tag-list .rb-tag-item', function () {
|
||||
let title = $(this).find('.rb_tag_tit').text();
|
||||
let url = $(this).attr('data-url');
|
||||
|
||||
if (!title || !url) {
|
||||
console.warn('제목 또는 URL이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
insertTag(title, url);
|
||||
});
|
||||
|
||||
// `Enter` 입력 시 태그 삽입
|
||||
$(document).on('keydown', function (e) {
|
||||
if (e.key === 'Enter' && $('#rb-tag-popup').is(':visible')) {
|
||||
e.preventDefault();
|
||||
|
||||
let query = $('#rb-tag-search').val().trim();
|
||||
|
||||
if (query) {
|
||||
let searchUrl = g5Config.g5_bbs_url + `/board.php?bo_table=` + g5Config.g5_bo_table + `&sop=and&sfl=wr_subject%7C%7Cwr_content&stx=${encodeURIComponent(query)}`;
|
||||
insertTag(query, searchUrl);
|
||||
} else {
|
||||
alert('검색어를 입력하세요.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 태그 삽입 (태그 삽입 시 `#` 제거)
|
||||
function insertTag(title, url) {
|
||||
restoreSelection();
|
||||
deleteHashBeforeCursor(); // 🌟 `#`을 커서 위치에서 삭제
|
||||
|
||||
let tagHtml = `<div class="rb_tag" contenteditable="false"><a href="${url}" target="_blank" title="게시물 바로가기"># ${title}</a></div> `;
|
||||
insertHTMLAtCursor(tagHtml);
|
||||
|
||||
$('#rb-tag-popup').fadeOut(100);
|
||||
}
|
||||
|
||||
// 🌟 커서 앞의 `#`을 삭제하는 함수 추가
|
||||
function deleteHashBeforeCursor() {
|
||||
let sel = window.getSelection();
|
||||
if (sel.rangeCount) {
|
||||
let range = sel.getRangeAt(0);
|
||||
let container = range.startContainer;
|
||||
let offset = range.startOffset;
|
||||
|
||||
if (container.nodeType === Node.TEXT_NODE && offset > 0) {
|
||||
let text = container.nodeValue;
|
||||
if (text[offset - 1] === "#") {
|
||||
container.nodeValue = text.slice(0, offset - 1) + text.slice(offset);
|
||||
range.setStart(container, offset - 1);
|
||||
range.setEnd(container, offset - 1);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `.rb_tag` 안으로 커서가 들어가지 않게 설정
|
||||
$(document).on('mousedown keydown', '.rb_tag', function (e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// ESC 키 입력 시 닫기
|
||||
$(document).on('keydown', function (e) {
|
||||
if (e.key === 'Escape' && $('#rb-tag-popup').is(':visible')) {
|
||||
$('#rb-tag-popup').fadeOut(100);
|
||||
}
|
||||
});
|
||||
|
||||
// 에디터 외부 클릭 시 닫기
|
||||
$(document).on('click', function (e) {
|
||||
if (!$(e.target).closest('#rb-tag-popup').length) {
|
||||
$('#rb-tag-popup').fadeOut(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
995
plugin/editor/rb.editor/js/text.js
Normal file
995
plugin/editor/rb.editor/js/text.js
Normal file
@ -0,0 +1,995 @@
|
||||
// 선택된 텍스트에 스타일 적용
|
||||
function applyStyleToSelection(styleName, styleValue) {
|
||||
const selection = window.getSelection();
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const fragment = range.cloneContents();
|
||||
const wrapper = document.createDocumentFragment();
|
||||
|
||||
Array.from(fragment.childNodes).forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
node.style[styleName] = styleValue;
|
||||
wrapper.appendChild(node);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const span = document.createElement('span');
|
||||
span.style[styleName] = styleValue;
|
||||
span.textContent = node.nodeValue;
|
||||
wrapper.appendChild(span);
|
||||
}
|
||||
});
|
||||
|
||||
range.deleteContents();
|
||||
range.insertNode(wrapper);
|
||||
|
||||
// 한글 초성 분리 방지를 위해 normalize() 실행
|
||||
document.getElementById('editor').normalize();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function toggleStyleToSelection(styleName, styleValue) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const fragment = range.extractContents();
|
||||
|
||||
const wrapper = document.createDocumentFragment();
|
||||
|
||||
Array.from(fragment.childNodes).forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// 기존 요소에 스타일이 이미 있으면 제거, 없으면 추가
|
||||
if (node.style[styleName] === styleValue) {
|
||||
node.style[styleName] = ''; // 스타일 제거
|
||||
} else {
|
||||
node.style[styleName] = styleValue; // 스타일 추가
|
||||
}
|
||||
wrapper.appendChild(node);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
// 텍스트 노드라면 새 <span> 태그로 감싸고 스타일 적용
|
||||
const span = document.createElement("span");
|
||||
span.style[styleName] = styleValue;
|
||||
span.textContent = node.textContent;
|
||||
wrapper.appendChild(span);
|
||||
}
|
||||
});
|
||||
|
||||
range.deleteContents();
|
||||
range.insertNode(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEditorContent() {
|
||||
const editor = document.getElementById('editor');
|
||||
|
||||
// ✅ 중첩된 <span> 병합 방지
|
||||
editor.querySelectorAll('span').forEach((span) => {
|
||||
const parent = span.parentElement;
|
||||
|
||||
// 부모가 <span>이고 스타일이 동일하면 병합
|
||||
if (parent.tagName === 'SPAN') {
|
||||
const sameStyle = [...span.style].every(prop => parent.style[prop] === span.style[prop]);
|
||||
if (sameStyle) {
|
||||
while (span.firstChild) {
|
||||
parent.insertBefore(span.firstChild, span);
|
||||
}
|
||||
parent.removeChild(span);
|
||||
}
|
||||
}
|
||||
|
||||
// 빈 <span> 제거
|
||||
if (!span.textContent.trim()) {
|
||||
span.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// ⚠️ <div> 요소 정리할 때도 기존 스타일 유지
|
||||
editor.querySelectorAll('div').forEach((div) => {
|
||||
if (div.childNodes.length === 1 && div.firstChild.tagName === 'SPAN') {
|
||||
div.replaceWith(...div.childNodes);
|
||||
}
|
||||
});
|
||||
|
||||
// ⚠️ 한글 조합이 분리되지 않도록 normalize() 적용하되 스타일 병합 방지
|
||||
setTimeout(() => {
|
||||
editor.normalize();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function toggleFontSizeToSelection(fontSize) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const fragment = range.extractContents(); // 선택된 내용을 추출
|
||||
|
||||
const wrapper = document.createDocumentFragment();
|
||||
|
||||
Array.from(fragment.childNodes).forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// 기존 요소라면 폰트 크기 스타일 추가
|
||||
node.style.fontSize = fontSize;
|
||||
wrapper.appendChild(node);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
// 텍스트 노드라면 새 <span> 태그로 감싸고 폰트 크기 적용
|
||||
const span = document.createElement("span");
|
||||
span.style.fontSize = fontSize;
|
||||
span.textContent = node.textContent;
|
||||
wrapper.appendChild(span);
|
||||
}
|
||||
});
|
||||
|
||||
// 선택된 영역에 새 내용 삽입
|
||||
range.deleteContents();
|
||||
range.insertNode(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
function applyColorWithoutAffectingStyles(color) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0); // 선택된 범위 가져오기
|
||||
const ancestor = range.commonAncestorContainer;
|
||||
|
||||
// 선택된 범위 내의 텍스트 노드를 탐색
|
||||
const walker = document.createTreeWalker(
|
||||
ancestor,
|
||||
NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
return range.intersectsNode(node) ?
|
||||
NodeFilter.FILTER_ACCEPT :
|
||||
NodeFilter.FILTER_REJECT;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let currentNode;
|
||||
while ((currentNode = walker.nextNode())) {
|
||||
wrapTextNodeInFont(currentNode, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <font> 태그 대신 <span> 태그를 사용하도록 변경
|
||||
function wrapTextNodeInFont(textNode, color) {
|
||||
const parent = textNode.parentNode;
|
||||
// 이미 동일한 컬러가 적용된 span이 있으면 중복 방지
|
||||
if (parent.tagName === 'SPAN' && parent.style.color === color) {
|
||||
return;
|
||||
}
|
||||
const span = document.createElement('span');
|
||||
span.style.color = color;
|
||||
span.textContent = textNode.nodeValue;
|
||||
parent.replaceChild(span, textNode);
|
||||
}
|
||||
|
||||
// 텍스트 노드를 <font> 태그로 감싸고 컬러 적용
|
||||
function wrapTextNodeInFontTag(textNode, color) {
|
||||
wrapTextNodeInFont(textNode, color);
|
||||
}
|
||||
|
||||
|
||||
// 컬러를 적용하는 함수 (기존 스타일 처리와 동일한 방식)
|
||||
function applyStyleWithColor(styleProperty, value) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const commonAncestor = range.commonAncestorContainer;
|
||||
|
||||
// 선택된 텍스트에만 스타일 적용
|
||||
const walker = document.createTreeWalker(
|
||||
commonAncestor,
|
||||
NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
return range.intersectsNode(node) ?
|
||||
NodeFilter.FILTER_ACCEPT :
|
||||
NodeFilter.FILTER_REJECT;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let currentNode;
|
||||
while ((currentNode = walker.nextNode())) {
|
||||
wrapTextInStyle(currentNode, styleProperty, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wrapTextInStyle(textNode, styleProperty, value) {
|
||||
const parent = textNode.parentNode;
|
||||
|
||||
// 이미 동일한 스타일이 적용된 경우 처리하지 않음
|
||||
if (parent.tagName === 'SPAN' && parent.style[styleProperty] === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.style[styleProperty] = value;
|
||||
span.textContent = textNode.nodeValue;
|
||||
|
||||
parent.replaceChild(span, textNode);
|
||||
}
|
||||
|
||||
function applyColorWithoutDiv(color) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0); // 선택된 범위 가져오기
|
||||
const commonAncestor = range.commonAncestorContainer;
|
||||
|
||||
// 모든 텍스트 노드를 포함한 범위 처리
|
||||
const walker = document.createTreeWalker(
|
||||
commonAncestor,
|
||||
NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
// 선택된 범위에 포함된 텍스트 노드만 처리
|
||||
return range.intersectsNode(node) ?
|
||||
NodeFilter.FILTER_ACCEPT :
|
||||
NodeFilter.FILTER_REJECT;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const nodesToWrap = [];
|
||||
let currentNode;
|
||||
|
||||
while ((currentNode = walker.nextNode())) {
|
||||
nodesToWrap.push(currentNode);
|
||||
}
|
||||
|
||||
nodesToWrap.forEach((node) => {
|
||||
wrapTextNodeInSpanWithoutDiv(node, color);
|
||||
});
|
||||
|
||||
// 선택 영역 삭제 및 새로운 노드 삽입
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(nodesToWrap[0], 0);
|
||||
newRange.setEnd(nodesToWrap[nodesToWrap.length - 1], nodesToWrap[nodesToWrap.length - 1].length);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
}
|
||||
}
|
||||
|
||||
// 텍스트 노드를 <span>으로 감싸고 div 생성을 방지
|
||||
function wrapTextNodeInSpanWithoutDiv(textNode, color) {
|
||||
const parent = textNode.parentNode;
|
||||
|
||||
// 이미 동일한 <span>이 적용되어 있는 경우 처리하지 않음
|
||||
if (parent.tagName === 'SPAN' && parent.style.color === color) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.style.color = color;
|
||||
|
||||
// 텍스트 노드를 <span>으로 감싸기
|
||||
span.textContent = textNode.nodeValue;
|
||||
parent.replaceChild(span, textNode);
|
||||
}
|
||||
|
||||
function applyColorToExactSelection(color) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0); // 선택된 범위 가져오기
|
||||
|
||||
// 선택한 범위를 분리
|
||||
const startContainer = range.startContainer;
|
||||
const endContainer = range.endContainer;
|
||||
|
||||
if (startContainer === endContainer && startContainer.nodeType === Node.TEXT_NODE) {
|
||||
// 단일 텍스트 노드 선택인 경우
|
||||
wrapPartialTextInSpan(range, color);
|
||||
} else {
|
||||
// 여러 노드가 선택된 경우
|
||||
splitAndWrapRange(range, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function splitAndWrapRange(range, color) {
|
||||
const commonAncestor = range.commonAncestorContainer;
|
||||
|
||||
// 선택된 범위 내의 노드를 추출하여 처리
|
||||
const fragment = range.cloneContents();
|
||||
const walker = document.createTreeWalker(
|
||||
fragment,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
let currentNode;
|
||||
const nodesToWrap = [];
|
||||
while ((currentNode = walker.nextNode())) {
|
||||
nodesToWrap.push(currentNode);
|
||||
}
|
||||
|
||||
// 선택된 텍스트를 각각 <span>으로 감싸기
|
||||
nodesToWrap.forEach((node) => {
|
||||
const span = document.createElement('span');
|
||||
span.style.color = color;
|
||||
span.textContent = node.textContent;
|
||||
|
||||
const parent = node.parentNode;
|
||||
if (parent) {
|
||||
parent.replaceChild(span, node);
|
||||
}
|
||||
});
|
||||
|
||||
// 선택 영역 삭제 및 새로운 노드 삽입
|
||||
range.deleteContents();
|
||||
range.insertNode(fragment);
|
||||
}
|
||||
|
||||
// 단일 텍스트 노드의 선택된 부분을 <span>으로 감싸기
|
||||
function wrapPartialTextInSpan(range, color) {
|
||||
const text = range.toString();
|
||||
if (!text.trim()) return;
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.style.color = color;
|
||||
span.textContent = text;
|
||||
|
||||
range.deleteContents(); // 선택된 텍스트 삭제
|
||||
range.insertNode(span); // <span> 삽입
|
||||
}
|
||||
|
||||
// 텍스트 노드를 <span>으로 감싸고 컬러 스타일 적용
|
||||
function wrapTextNodeInSpan(textNode, color) {
|
||||
const parent = textNode.parentNode;
|
||||
|
||||
// 이미 <span>으로 감싸져 있고 동일한 컬러인 경우 처리하지 않음
|
||||
if (parent.tagName === 'SPAN' && parent.style.color === color) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.style.color = color;
|
||||
span.textContent = textNode.textContent;
|
||||
|
||||
parent.replaceChild(span, textNode);
|
||||
}
|
||||
|
||||
function applyColorToSelectionSafely(color) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0); // 선택된 범위 가져오기
|
||||
const ancestor = range.commonAncestorContainer;
|
||||
|
||||
// 순회하며 모든 텍스트 노드에 컬러 적용
|
||||
const walker = document.createTreeWalker(
|
||||
ancestor,
|
||||
NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
return range.intersectsNode(node) ?
|
||||
NodeFilter.FILTER_ACCEPT :
|
||||
NodeFilter.FILTER_REJECT;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let currentNode;
|
||||
const nodesToWrap = [];
|
||||
while ((currentNode = walker.nextNode())) {
|
||||
nodesToWrap.push(currentNode);
|
||||
}
|
||||
|
||||
nodesToWrap.forEach((node) => {
|
||||
wrapTextNodeInSpan(node, color);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyColorToMultipleBlocks(color) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0); // 선택된 범위 가져오기
|
||||
const startContainer = range.startContainer;
|
||||
const endContainer = range.endContainer;
|
||||
|
||||
if (startContainer !== endContainer) {
|
||||
// 선택 영역이 여러 블록에 걸쳐 있는 경우 처리
|
||||
handleMultiBlockSelection(range, color);
|
||||
} else {
|
||||
// 단일 블록 내에서 처리
|
||||
applyColorToSelection(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMultiBlockSelection(range, color) {
|
||||
const startContainer = range.startContainer;
|
||||
const endContainer = range.endContainer;
|
||||
|
||||
const ancestor = range.commonAncestorContainer;
|
||||
|
||||
// 순회하며 모든 텍스트 노드에 컬러 적용
|
||||
const walker = document.createTreeWalker(
|
||||
ancestor,
|
||||
NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
const nodeRange = document.createRange();
|
||||
nodeRange.selectNodeContents(node);
|
||||
|
||||
// 텍스트 노드가 선택 범위에 포함된 경우만 처리
|
||||
return range.intersectsNode(node) ?
|
||||
NodeFilter.FILTER_ACCEPT :
|
||||
NodeFilter.FILTER_REJECT;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let currentNode;
|
||||
while ((currentNode = walker.nextNode())) {
|
||||
wrapTextInSpan(currentNode, color);
|
||||
}
|
||||
|
||||
// 선택 영역 정리
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
// 텍스트 노드를 <span>으로 감싸고 컬러 스타일 적용
|
||||
function wrapTextInSpan(textNode, color) {
|
||||
const span = document.createElement('span');
|
||||
span.style.color = color;
|
||||
span.textContent = textNode.textContent;
|
||||
|
||||
const parent = textNode.parentNode;
|
||||
parent.replaceChild(span, textNode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function applyColorWithSpan(color) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0); // 선택된 범위 가져오기
|
||||
const fragment = range.extractContents(); // 선택된 내용을 추출
|
||||
|
||||
const wrapper = document.createDocumentFragment();
|
||||
|
||||
Array.from(fragment.childNodes).forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// 기존 요소를 <span>으로 감싸기
|
||||
const span = document.createElement('span');
|
||||
span.style.color = color;
|
||||
span.appendChild(node.cloneNode(true));
|
||||
wrapper.appendChild(span);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
// 텍스트 노드를 <span>으로 감싸기
|
||||
const span = document.createElement('span');
|
||||
span.style.color = color;
|
||||
span.textContent = node.textContent;
|
||||
wrapper.appendChild(span);
|
||||
}
|
||||
});
|
||||
|
||||
range.deleteContents(); // 기존 내용을 삭제
|
||||
range.insertNode(wrapper); // 새로 생성한 내용 삽입
|
||||
}
|
||||
}
|
||||
|
||||
// 선택된 텍스트에 컬러를 적용하는 함수
|
||||
function applyColorToSelection(color) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0); // 선택된 범위 가져오기
|
||||
const commonAncestor = range.commonAncestorContainer;
|
||||
|
||||
// 모든 텍스트 노드를 포함한 범위 처리
|
||||
const walker = document.createTreeWalker(
|
||||
commonAncestor,
|
||||
NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
// 선택된 범위에 포함된 텍스트 노드만 처리
|
||||
return range.intersectsNode(node) ?
|
||||
NodeFilter.FILTER_ACCEPT :
|
||||
NodeFilter.FILTER_REJECT;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let currentNode;
|
||||
const nodesToWrap = [];
|
||||
while ((currentNode = walker.nextNode())) {
|
||||
nodesToWrap.push(currentNode);
|
||||
}
|
||||
|
||||
// 모든 텍스트 노드에 <span> 태그 추가
|
||||
nodesToWrap.forEach((node) => {
|
||||
wrapNodeInSpan(node, color);
|
||||
});
|
||||
|
||||
// 선택 영역 복원
|
||||
restoreSelection();
|
||||
}
|
||||
}
|
||||
|
||||
function wrapNodeInSpan(node, color) {
|
||||
const parent = node.parentNode;
|
||||
|
||||
// 이미 동일한 <span>이 적용되어 있는 경우 처리하지 않음
|
||||
if (parent.tagName === 'SPAN' && parent.style.color === color) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.style.color = color;
|
||||
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
span.textContent = node.textContent; // 텍스트 노드 복사
|
||||
} else {
|
||||
span.innerHTML = node.outerHTML; // 요소 노드 복사
|
||||
}
|
||||
|
||||
parent.replaceChild(span, node);
|
||||
}
|
||||
|
||||
function normalizeSpans() {
|
||||
$('#editor span').each(function () {
|
||||
const $this = $(this);
|
||||
const parent = $this.parent();
|
||||
|
||||
// 부모가 <span>이고, 스타일이 동일하면 병합
|
||||
if (parent.is('span') && parent.css('color') === $this.css('color')) {
|
||||
$this.contents().unwrap(); // 현재 <span>을 제거하고 부모로 병합
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyFontTagWithColor(color) {
|
||||
document.execCommand('styleWithCSS', false, false); // CSS 스타일 대신 태그 사용
|
||||
document.execCommand('foreColor', false, color); // 컬러 적용
|
||||
}
|
||||
function applyColorWithExecCommand(color) {
|
||||
// 컬러 변경 명령 실행
|
||||
document.execCommand('foreColor', false, color);
|
||||
|
||||
// #editor 영역 내의 모든 <font> 태그를 찾아 <span>으로 변경
|
||||
$('#editor font').each(function () {
|
||||
const $font = $(this);
|
||||
// <font> 태그의 color 속성을 읽음
|
||||
let fontColor = $font.attr('color') || $font.css('color');
|
||||
|
||||
// 새 <span> 태그 생성 후 스타일과 내용을 복사
|
||||
const $span = $('<span>').css('color', fontColor).html($font.html());
|
||||
|
||||
// <font> 태그를 새 <span> 태그로 교체
|
||||
$font.replaceWith($span);
|
||||
});
|
||||
}
|
||||
|
||||
// 컬러 선택 후 텍스트에 색상 적용
|
||||
$('#text-color-picker').on('input', function () {
|
||||
const selectedColor = $(this).val(); // 선택된 색상 값
|
||||
$('#color-btn .color-btn-svg path.path2_color1').attr('fill', selectedColor); // SVG의 fill 속성 업데이트
|
||||
$('#color-btn .color-btn-svg path.path2_color2').attr('fill', selectedColor); // SVG의 fill 속성 업데이트
|
||||
restoreSelection(); // 선택 영역 복원
|
||||
applyTextColor(selectedColor); // 컬러 변경
|
||||
saveSelection(); // 선택 영역 다시 저장
|
||||
});
|
||||
|
||||
$('#background-color-picker').on('input', function () {
|
||||
const selectedBgColor = $(this).val();
|
||||
restoreSelection();
|
||||
//applyBackgroundColor(selectedBgColor);
|
||||
applyBackgroundColorNoBreaking(selectedBgColor);
|
||||
|
||||
// 선택된 컬러를 SVG 등에 업데이트
|
||||
$('#background-color-btn .background-color-btn-svg path').attr('fill', selectedBgColor);
|
||||
saveSelection();
|
||||
});
|
||||
|
||||
|
||||
// 컬러 버튼 클릭 시 선택 영역 저장
|
||||
$('#color-btn').on('mousedown', function (e) {
|
||||
saveSelection(); // 선택 영역 저장
|
||||
e.preventDefault(); // 기본 동작 방지
|
||||
});
|
||||
|
||||
// 텍스트 선택 후 컬러 버튼 클릭 시 Coloris 활성화
|
||||
$('#color-btn').click(function (e) {
|
||||
e.preventDefault(); // 기본 동작 방지
|
||||
saveSelection(); // 선택 영역 저장
|
||||
$('#text-color-picker').click(); // 컬러 선택기 활성화
|
||||
});
|
||||
|
||||
// 텍스트 선택 후 백그라운드 버튼 클릭 시 Coloris 활성화
|
||||
$('#background-color-btn').click(function (e) {
|
||||
e.preventDefault(); // 기본 동작 방지
|
||||
saveSelection(); // 선택 영역 저장
|
||||
$('#background-color-picker').click(); // 컬러 선택기 활성화
|
||||
});
|
||||
|
||||
|
||||
function applyTextColor(color) {
|
||||
if (currentMode === 'regular') {
|
||||
if (savedSelection) {
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(savedSelection);
|
||||
}
|
||||
document.execCommand('foreColor', false, color);
|
||||
updateMiniToolbar();
|
||||
// #mini-toolbar는 숨기지 않음
|
||||
} else if (currentMode === 'canvas') {
|
||||
applyCanvasTextColor(color);
|
||||
}
|
||||
}
|
||||
|
||||
function applyBackgroundColorNoBreaking(color) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection.rangeCount || selection.isCollapsed) return;
|
||||
|
||||
// 현재 선택 범위 가져오기
|
||||
const range = selection.getRangeAt(0);
|
||||
const startContainer = range.startContainer;
|
||||
const endContainer = range.endContainer;
|
||||
|
||||
// ✅ 두 줄 이상 선택한 경우 기존 로직 사용
|
||||
if (startContainer !== endContainer) {
|
||||
const textNodes = getTextNodesInRange(range);
|
||||
textNodes.forEach(node => highlightTextNodePartially(node, range, color));
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ 한 줄 부분 선택인 경우 일반적인 스타일 적용 방식 사용
|
||||
applyStyleToSelection("backgroundColor", color);
|
||||
}
|
||||
|
||||
function highlightTextNodePartially(textNode, selectionRange, color) {
|
||||
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return;
|
||||
|
||||
const nodeRange = document.createRange();
|
||||
nodeRange.selectNodeContents(textNode);
|
||||
|
||||
const intersectionRange = nodeRange.cloneRange();
|
||||
|
||||
if (selectionRange.compareBoundaryPoints(Range.START_TO_START, nodeRange) > 0) {
|
||||
intersectionRange.setStart(selectionRange.startContainer, selectionRange.startOffset);
|
||||
}
|
||||
if (selectionRange.compareBoundaryPoints(Range.END_TO_END, nodeRange) < 0) {
|
||||
intersectionRange.setEnd(selectionRange.endContainer, selectionRange.endOffset);
|
||||
}
|
||||
|
||||
const selectedText = intersectionRange.toString();
|
||||
if (!selectedText.trim()) return;
|
||||
|
||||
const startOffsetInNode = intersectionRange.startOffset - nodeRange.startOffset;
|
||||
const endOffsetInNode = intersectionRange.endOffset - nodeRange.startOffset;
|
||||
|
||||
const originalText = textNode.nodeValue;
|
||||
const beforeText = originalText.slice(0, startOffsetInNode);
|
||||
const middleText = originalText.slice(startOffsetInNode, endOffsetInNode);
|
||||
const afterText = originalText.slice(endOffsetInNode);
|
||||
|
||||
let highlightSpan;
|
||||
if (textNode.parentNode.tagName === 'SPAN') {
|
||||
highlightSpan = textNode.parentNode.cloneNode();
|
||||
highlightSpan.style.backgroundColor = color;
|
||||
} else {
|
||||
highlightSpan = document.createElement('span');
|
||||
highlightSpan.style.backgroundColor = color;
|
||||
}
|
||||
|
||||
highlightSpan.textContent = middleText;
|
||||
|
||||
const parent = textNode.parentNode;
|
||||
if (!parent) return;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
if (beforeText) {
|
||||
fragment.appendChild(document.createTextNode(beforeText));
|
||||
}
|
||||
fragment.appendChild(highlightSpan);
|
||||
if (afterText) {
|
||||
fragment.appendChild(document.createTextNode(afterText));
|
||||
}
|
||||
|
||||
parent.replaceChild(fragment, textNode);
|
||||
}
|
||||
|
||||
|
||||
function getTextNodesInRange(range) {
|
||||
const container = range.commonAncestorContainer;
|
||||
const textNodes = [];
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
container,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) => {
|
||||
return range.intersectsNode(node)
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
while (walker.nextNode()) {
|
||||
textNodes.push(walker.currentNode);
|
||||
}
|
||||
return textNodes;
|
||||
}
|
||||
|
||||
function mergeBackgroundSpans(editor) {
|
||||
$(editor).find('span').each(function () {
|
||||
const $span = $(this);
|
||||
|
||||
// 부모가 span이고 스타일이 같으면 병합
|
||||
const parent = $span.parent();
|
||||
if (parent.is('span') && parent.css('background-color') === $span.css('background-color')) {
|
||||
$span.contents().unwrap(); // span 병합
|
||||
}
|
||||
|
||||
// 빈 span 제거
|
||||
if ($span.text().trim() === '' && $span.children().length === 0) {
|
||||
$span.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyBackgroundColor(color) {
|
||||
if (savedSelection) {
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(savedSelection);
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const commonAncestor = range.commonAncestorContainer;
|
||||
|
||||
// ✅ 드래그로 여러 개의 `<td>`, `<th>`가 선택된 경우 처리
|
||||
const selectedCells = getSelectedCells(range);
|
||||
|
||||
if (selectedCells.length > 0) {
|
||||
selectedCells.forEach(cell => {
|
||||
applyBackgroundToCell(cell, color);
|
||||
});
|
||||
} else {
|
||||
// ✅ 단일 선택일 경우 기존 로직 사용
|
||||
let targetElement = commonAncestor.nodeType === Node.ELEMENT_NODE ? commonAncestor : commonAncestor.parentElement;
|
||||
|
||||
if (targetElement.tagName === 'TD' || targetElement.tagName === 'TH') {
|
||||
applyBackgroundToCell(targetElement, color);
|
||||
} else {
|
||||
wrapTextWithSpan(range, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateMiniToolbar();
|
||||
}
|
||||
|
||||
// ✅ 선택된 `<td>`, `<th>` 목록 가져오기
|
||||
function getSelectedCells(range) {
|
||||
const selectedCells = [];
|
||||
const ancestor = range.commonAncestorContainer;
|
||||
const table = ancestor.closest ? ancestor.closest('table') : null;
|
||||
|
||||
if (table) {
|
||||
const allCells = table.querySelectorAll('td, th');
|
||||
allCells.forEach(cell => {
|
||||
if (range.intersectsNode(cell)) {
|
||||
selectedCells.push(cell);
|
||||
}
|
||||
});
|
||||
}
|
||||
return selectedCells;
|
||||
}
|
||||
|
||||
// ✅ 개별 `<td>` 또는 `<th>` 내부의 텍스트에만 배경색 적용
|
||||
function applyBackgroundToCell(cell, color) {
|
||||
// 기존 `span`이 있는지 확인하고 스타일 변경
|
||||
let existingSpan = cell.querySelector('span');
|
||||
|
||||
if (existingSpan) {
|
||||
existingSpan.style.backgroundColor = color;
|
||||
} else {
|
||||
// 기존 `span`이 없으면 내부 텍스트를 `span`으로 감싸기
|
||||
cell.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim() !== '') {
|
||||
const span = document.createElement("span");
|
||||
span.style.backgroundColor = color;
|
||||
span.textContent = node.nodeValue;
|
||||
node.replaceWith(span);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 선택한 텍스트를 개별적으로 감싸서 스타일 적용하는 함수
|
||||
function wrapTextWithSpan(range, color) {
|
||||
const span = document.createElement("span");
|
||||
span.style.backgroundColor = color;
|
||||
|
||||
// 선택한 내용을 가져옴
|
||||
const selectedContents = range.extractContents();
|
||||
|
||||
// 선택한 텍스트를 감싸서 배경색 적용
|
||||
span.appendChild(selectedContents);
|
||||
|
||||
// 기존 내용을 삭제하고 새로 추가
|
||||
range.deleteContents();
|
||||
range.insertNode(span);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function applyFontSize(fontSize) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection.rangeCount || selection.isCollapsed) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// 시작/끝 컨테이너가 텍스트 노드라면 부모 요소로 변경
|
||||
const startContainer =
|
||||
range.startContainer.nodeType === Node.TEXT_NODE
|
||||
? range.startContainer.parentElement
|
||||
: range.startContainer;
|
||||
const endContainer =
|
||||
range.endContainer.nodeType === Node.TEXT_NODE
|
||||
? range.endContainer.parentElement
|
||||
: range.endContainer;
|
||||
|
||||
// 시작과 끝이 각각 td 또는 th 내부에 있는지 확인
|
||||
const startCell =
|
||||
startContainer.closest && startContainer.closest("td, th");
|
||||
const endCell =
|
||||
endContainer.closest && endContainer.closest("td, th");
|
||||
|
||||
// 두 셀 모두 존재하고, 같은 테이블에 속해 있다면
|
||||
if (
|
||||
startCell &&
|
||||
endCell &&
|
||||
startCell.closest("table") === endCell.closest("table")
|
||||
) {
|
||||
// 테이블 내 모든 셀 중 선택 영역과 교차하는 셀에 대해 스타일 적용
|
||||
const table = startCell.closest("table");
|
||||
const cells = table.querySelectorAll("td, th");
|
||||
|
||||
cells.forEach((cell) => {
|
||||
// Range API의 intersectsNode 메서드를 사용하여
|
||||
// 해당 셀과 선택 영역이 겹치는지 검사
|
||||
if (range.intersectsNode(cell)) {
|
||||
cell.style.fontSize = fontSize;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 테이블 셀 내부가 아닌 경우 – 기존 로직대로 선택 영역 내부의 텍스트/요소에 span 등을 삽입하여 폰트 크기 적용
|
||||
const extractedContents = range.extractContents();
|
||||
const wrapper = document.createDocumentFragment();
|
||||
|
||||
Array.from(extractedContents.childNodes).forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
// 텍스트 노드인 경우 새로운 span으로 감싸서 적용
|
||||
const span = document.createElement("span");
|
||||
span.style.fontSize = fontSize;
|
||||
span.textContent = node.textContent;
|
||||
wrapper.appendChild(span);
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// 요소 노드인 경우 style만 변경
|
||||
node.style.fontSize = fontSize;
|
||||
wrapper.appendChild(node);
|
||||
}
|
||||
});
|
||||
|
||||
range.insertNode(wrapper);
|
||||
// 편집 영역(normalize)에서 불필요한 텍스트 노드 분리(한글 초성 분리 방지)
|
||||
document.getElementById("editor").normalize();
|
||||
}
|
||||
|
||||
|
||||
// ✅ `td`, `th` 내부에서 폰트 크기 변경 (새로운 `<td>`, `<th>` 생성 방지)
|
||||
function applyFontSizeInsideTable(fontSize, $currentCell) {
|
||||
const selection = window.getSelection();
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const selectedNodes = [];
|
||||
|
||||
const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT, null);
|
||||
let currentNode;
|
||||
|
||||
while ((currentNode = walker.nextNode())) {
|
||||
if (selection.containsNode(currentNode, true)) {
|
||||
selectedNodes.push(currentNode);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStyleToSelection(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
|
||||
let parent = node.parentNode;
|
||||
|
||||
// ✅ `td`, `th` 자체가 아니라 내부 텍스트에만 스타일 적용
|
||||
if ($currentCell[0] === parent) {
|
||||
const span = document.createElement("span");
|
||||
span.style.fontSize = fontSize;
|
||||
span.textContent = node.textContent;
|
||||
parent.replaceChild(span, node);
|
||||
} else if (parent.tagName === "SPAN") {
|
||||
parent.style.fontSize = fontSize;
|
||||
} else {
|
||||
const span = document.createElement("span");
|
||||
span.style.fontSize = fontSize;
|
||||
span.appendChild(node.cloneNode(true));
|
||||
parent.replaceChild(span, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedNodes.forEach(applyStyleToSelection);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ `td`, `th` 외부 또는 `td`, `th` 내부의 텍스트에서 폰트 크기 변경 (새로운 `<span>` 추가)
|
||||
function applyFontSizeOutsideTable(fontSize) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const fragment = range.cloneContents(); // ✅ 기존 구조 유지
|
||||
|
||||
const wrapper = document.createDocumentFragment();
|
||||
|
||||
Array.from(fragment.childNodes).forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
node.style.fontSize = fontSize;
|
||||
wrapper.appendChild(node);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const span = document.createElement("span");
|
||||
span.style.fontSize = fontSize;
|
||||
span.textContent = node.textContent;
|
||||
wrapper.appendChild(span);
|
||||
}
|
||||
});
|
||||
|
||||
range.deleteContents();
|
||||
range.insertNode(wrapper);
|
||||
} else {
|
||||
if (!$('#editor').text().trim()) {
|
||||
$('#editor').css('font-size', fontSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function alignText(alignment) {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
|
||||
if (currentMode === 'regular') {
|
||||
const $currentCell = $(selection.anchorNode).closest('td, th');
|
||||
|
||||
if ($currentCell.length) {
|
||||
// 커서가 표 안에 있는 경우 해당 셀에만 정렬 적용
|
||||
$currentCell.css('text-align', alignment);
|
||||
} else {
|
||||
// 커서가 표 밖에 있으면 전체 문서에 정렬 적용 (표는 제외)
|
||||
$('#editor').children(':not(table)').css('text-align', alignment);
|
||||
}
|
||||
} else if (currentMode === 'canvas') {
|
||||
const activeObject = fabricCanvas.getActiveObject();
|
||||
if (!activeObject) return;
|
||||
|
||||
if (activeObject.type === 'i-text') {
|
||||
activeObject.set('textAlign', alignment);
|
||||
fabricCanvas.renderAll();
|
||||
}
|
||||
}
|
||||
|
||||
updateMiniToolbar();
|
||||
}
|
||||
219
plugin/editor/rb.editor/js/upload.js
Normal file
219
plugin/editor/rb.editor/js/upload.js
Normal file
@ -0,0 +1,219 @@
|
||||
let uploadedImages = [];
|
||||
|
||||
// 업로드 시 Nonce 값이 없으면 iframe에서 요청 후 대기
|
||||
async function uploadImage(file, options = { insert: true }) {
|
||||
if (typeof file === 'string') {
|
||||
// SVG 파일은 변환하지 않고 바로 업로드
|
||||
if (file.endsWith(".svg")) {
|
||||
//console.log("SVG 파일 감지:", file);
|
||||
} else {
|
||||
file = await convertImageUrlToFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
//console.error("올바른 이미지 파일이 아닙니다:", file);
|
||||
alert("이미지 파일을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ed_nonce) {
|
||||
ed_nonce = await requestNonceFromParent();
|
||||
}
|
||||
|
||||
if (!ed_nonce) {
|
||||
alert("보안 토큰이 유효하지 않습니다. 새로고침 후 다시 시도해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ 로딩 오버레이 표시
|
||||
const loadingOverlay = document.querySelector('.loadingOverlay.loadingOverlay_ai');
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.style.display = 'block';
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("editor_nonce", ed_nonce);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
url: g5Config.g5_editor_url + "/php/rb.upload.php",
|
||||
type: "POST",
|
||||
data: formData,
|
||||
contentType: false,
|
||||
processData: false,
|
||||
dataType: "json",
|
||||
success: function (response) {
|
||||
if (response.files && response.files[0] && response.files[0].url) {
|
||||
let imageUrl = response.files[0].url;
|
||||
|
||||
// 업로드 성공 후 로딩 숨김
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
// SVG 업로드 후 처리
|
||||
//console.log("업로드 성공:", imageUrl);
|
||||
|
||||
// 일반 첨부용일 때만 insertImage() 호출
|
||||
if (options.insert) {
|
||||
insertImage(imageUrl);
|
||||
}
|
||||
resolve(imageUrl);
|
||||
} else {
|
||||
alert("이미지 업로드에 실패 하였습니다.");
|
||||
reject("이미지 업로드에 실패 하였습니다");
|
||||
}
|
||||
},
|
||||
error: function (error) {
|
||||
console.error("이미지 업로드 실패:", error);
|
||||
reject(error);
|
||||
},
|
||||
complete: function () {
|
||||
// ✅ 업로드가 끝나면 무조건 로딩 숨김
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
$('#editor').on('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$(this).addClass('dragover');
|
||||
});
|
||||
|
||||
$('#editor').on('dragleave', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$(this).removeClass('dragover');
|
||||
});
|
||||
|
||||
$('#editor').on('drop', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$(this).removeClass('dragover');
|
||||
|
||||
const files = e.originalEvent.dataTransfer.files;
|
||||
if (!files.length) return;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
uploadImage(files[i]);
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 선택 이미지 업로드 (input[type="file"])
|
||||
$('#image-upload').change(function (event) {
|
||||
const files = event.target.files;
|
||||
if (!files.length) return;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
uploadImage(files[i]);
|
||||
}
|
||||
|
||||
$('#image-upload').val('');
|
||||
});
|
||||
|
||||
function insertImage(imageUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = imageUrl;
|
||||
img.alt = "Uploaded Image";
|
||||
img.crossOrigin = "anonymous";
|
||||
img.draggable = false; // 드래그 방지
|
||||
img.style.width = '100%'; // 가로는 부모 요소에 맞춤
|
||||
|
||||
img.onload = function () {
|
||||
// 이미지의 원본 크기
|
||||
const imgWidth = img.naturalWidth;
|
||||
const imgHeight = img.naturalHeight;
|
||||
// data‑ratio는 이미지의 원본 높이/원본 너비
|
||||
const ratio = imgHeight / imgWidth;
|
||||
|
||||
const editor = document.getElementById('editor');
|
||||
const editorWidth = editor.offsetWidth; // 에디터의 가로 크기
|
||||
|
||||
// .resizable_wrap 생성
|
||||
const wrap = document.createElement('div');
|
||||
wrap.classList.add('resizable_wrap');
|
||||
|
||||
// .resizable div 생성
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('resizable');
|
||||
|
||||
// 에디터보다 클 경우 100%, 작으면 원본 크기 사용하여 width 설정
|
||||
if (imgWidth > editorWidth) {
|
||||
wrapper.style.width = '100%';
|
||||
} else {
|
||||
wrapper.style.width = `${imgWidth}px`;
|
||||
}
|
||||
// 원본 크기를 data 속성에 저장 (항상 갱신)
|
||||
wrapper.dataset.originalWidth = imgWidth;
|
||||
wrapper.dataset.originalHeight = imgHeight;
|
||||
wrapper.dataset.ratio = ratio;
|
||||
|
||||
// 초기 높이 계산 (현재 width에 data‑ratio를 곱함)
|
||||
wrapper.style.height = `${wrapper.offsetWidth * ratio}px`;
|
||||
|
||||
// 크기 조절 핸들 추가
|
||||
const resizeHandle = document.createElement('div');
|
||||
resizeHandle.classList.add('resize-handle');
|
||||
|
||||
// 이미지와 핸들 추가
|
||||
wrapper.appendChild(img);
|
||||
wrapper.appendChild(resizeHandle);
|
||||
wrap.appendChild(wrapper);
|
||||
|
||||
// 줄바꿈용 <p><br></p> 추가
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.appendChild(document.createElement('br'));
|
||||
|
||||
// 현재 커서 위치 확인 후 에디터 내 삽입
|
||||
const selection = window.getSelection();
|
||||
const range = selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||
|
||||
if (range) {
|
||||
let container = range.commonAncestorContainer;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
container = container.parentElement;
|
||||
}
|
||||
if ($(container).closest('#editor').length) {
|
||||
range.deleteContents();
|
||||
range.insertNode(wrap);
|
||||
wrap.parentNode.insertBefore(paragraph, wrap.nextSibling);
|
||||
range.setStart(paragraph, 0);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else {
|
||||
editor.appendChild(wrap);
|
||||
editor.appendChild(paragraph);
|
||||
}
|
||||
} else {
|
||||
editor.appendChild(wrap);
|
||||
editor.appendChild(paragraph);
|
||||
}
|
||||
|
||||
// 갱신: 현재 wrapper의 높이를 data‑ratio에 따라 재계산
|
||||
wrapper.style.height = `${wrapper.offsetWidth * ratio}px`;
|
||||
|
||||
// 창 크기가 변경될 때 높이 업데이트
|
||||
function updateHeight() {
|
||||
const currentWidth = wrapper.offsetWidth;
|
||||
const originalWidth = parseFloat(wrapper.dataset.originalWidth) || currentWidth;
|
||||
const originalHeight = parseFloat(wrapper.dataset.originalHeight) || currentWidth * ratio;
|
||||
const originalRatio = parseFloat(wrapper.dataset.ratio) || (originalHeight / originalWidth);
|
||||
|
||||
wrapper.style.height = `${currentWidth * originalRatio}px`;
|
||||
}
|
||||
window.addEventListener("resize", updateHeight);
|
||||
|
||||
// 크기 조절 기능 활성화 (이 함수 내에서 리사이즈 후에도 data‑속성이 갱신되도록 구현 가능)
|
||||
makeImageResizableWithObserver($(wrapper));
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user