1209 lines
42 KiB
JavaScript
1209 lines
42 KiB
JavaScript
/**
|
||
* 글벗 Light - 편집 바 모듈
|
||
* editor.js
|
||
*/
|
||
|
||
// ===== 전역 변수 =====
|
||
let isEditing = false;
|
||
let activeBlock = null;
|
||
let historyStack = [];
|
||
let redoStack = [];
|
||
const MAX_HISTORY = 50;
|
||
let isApplyingFormat = false;
|
||
|
||
// ===== 편집 바 HTML 생성 =====
|
||
// ===== 편집 바 HTML 생성 =====
|
||
function createFormatBar() {
|
||
const formatBarHTML = `
|
||
<div class="format-bar" id="formatBar">
|
||
<select class="format-select" id="fontFamily" onchange="applyFontFamily(this.value)">
|
||
<option value="Noto Sans KR">Noto Sans KR</option>
|
||
<option value="맑은 고딕">맑은 고딕</option>
|
||
<option value="나눔고딕">나눔고딕</option>
|
||
<option value="돋움">돋움</option>
|
||
</select>
|
||
<button class="format-btn" onclick="loadLocalFonts()">📁<span class="tooltip">폰트 불러오기</span></button>
|
||
<input type="number" class="format-select" id="fontSizeInput" value="12" min="8" max="72"
|
||
style="width:55px;" onchange="applyFontSizeInput(this.value)">
|
||
<div class="format-divider"></div>
|
||
<button class="format-btn" onclick="formatText('bold')"><b>B</b><span class="tooltip">굵게</span></button>
|
||
<button class="format-btn" onclick="formatText('italic')"><i>I</i><span class="tooltip">기울임</span></button>
|
||
<button class="format-btn" onclick="formatText('underline')"><u>U</u><span class="tooltip">밑줄</span></button>
|
||
<button class="format-btn" onclick="formatText('strikeThrough')"><s>S</s><span class="tooltip">취소선</span></button>
|
||
<div class="format-divider"></div>
|
||
<select class="format-select" onchange="if(this.value) formatText(this.value); this.selectedIndex=0;">
|
||
<option value="">정렬 ▾</option>
|
||
<option value="justifyLeft">⫷ 왼쪽</option>
|
||
<option value="justifyCenter">☰ 가운데</option>
|
||
<option value="justifyRight">⫸ 오른쪽</option>
|
||
</select>
|
||
<select class="format-select" onchange="if(this.value) adjustLetterSpacing(parseFloat(this.value)); this.selectedIndex=0;">
|
||
<option value="">자간 ▾</option>
|
||
<option value="-0.5">좁게</option>
|
||
<option value="-1">더 좁게</option>
|
||
<option value="0.5">넓게</option>
|
||
<option value="1">더 넓게</option>
|
||
</select>
|
||
<div class="format-divider"></div>
|
||
<div class="color-picker-btn format-btn">
|
||
<span style="border-bottom:3px solid #000;">A</span>
|
||
<input type="color" id="textColor" value="#000000" onchange="applyTextColor(this.value)">
|
||
</div>
|
||
<div class="color-picker-btn format-btn">
|
||
<span style="background:#ff0;padding:0 4px;">A</span>
|
||
<input type="color" id="bgColor" value="#ffff00" onchange="applyBgColor(this.value)">
|
||
</div>
|
||
<select class="format-select" onchange="handleInsert(this.value); this.selectedIndex=0;">
|
||
<option value="">삽입 ▾</option>
|
||
<option value="table">▦ 표</option>
|
||
<option value="image">🖼️ 그림</option>
|
||
<option value="hr">― 구분선</option>
|
||
</select>
|
||
<select class="format-select" onchange="applyHeading(this.value)">
|
||
<option value="">본문</option>
|
||
<option value="h1">제목1</option>
|
||
<option value="h2">제목2</option>
|
||
<option value="h3">제목3</option>
|
||
</select>
|
||
<div class="format-divider"></div>
|
||
<button class="format-btn page-btn" onclick="smartAlign()">🔄 지능형 정렬</button>
|
||
<button class="format-btn page-btn" onclick="forcePageBreak()">📄 새페이지</button>
|
||
<button class="format-btn page-btn" onclick="moveToPrevPage()">📤 전페이지</button>
|
||
</div>
|
||
`;
|
||
return formatBarHTML;
|
||
}
|
||
|
||
// ===== 로컬 폰트 불러오기 =====
|
||
async function loadLocalFonts() {
|
||
// API 지원 여부 확인
|
||
if (!('queryLocalFonts' in window)) {
|
||
toast('⚠️ 이 브라우저는 폰트 불러오기를 지원하지 않습니다 (Chrome/Edge 필요)');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
toast('🔄 폰트 불러오는 중...');
|
||
|
||
// 사용자 권한 요청 & 폰트 목록 가져오기
|
||
const fonts = await window.queryLocalFonts();
|
||
const fontSelect = document.getElementById('fontFamily');
|
||
|
||
// 기존 옵션들의 값 수집 (중복 방지)
|
||
const existingFonts = new Set();
|
||
fontSelect.querySelectorAll('option').forEach(opt => {
|
||
existingFonts.add(opt.value);
|
||
});
|
||
|
||
// 중복 제거 (family 기준)
|
||
const families = [...new Set(fonts.map(f => f.family))];
|
||
|
||
// 구분선 추가
|
||
const separator = document.createElement('option');
|
||
separator.disabled = true;
|
||
separator.textContent = '──── 내 컴퓨터 ────';
|
||
fontSelect.appendChild(separator);
|
||
|
||
// 새 폰트 추가
|
||
let addedCount = 0;
|
||
families.sort().forEach(family => {
|
||
if (!existingFonts.has(family)) {
|
||
const option = document.createElement('option');
|
||
option.value = family;
|
||
option.textContent = family;
|
||
fontSelect.appendChild(option);
|
||
addedCount++;
|
||
}
|
||
});
|
||
|
||
toast(`✅ ${addedCount}개 폰트 추가됨 (총 ${families.length}개)`);
|
||
|
||
} catch (e) {
|
||
if (e.name === 'NotAllowedError') {
|
||
toast('⚠️ 폰트 접근 권한이 거부되었습니다');
|
||
} else {
|
||
console.error('폰트 로드 오류:', e);
|
||
toast('❌ 폰트 불러오기 실패: ' + e.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 삽입 핸들러 =====
|
||
function handleInsert(type) {
|
||
if (type === 'table') openTableModal();
|
||
else if (type === 'image') insertImage();
|
||
else if (type === 'hr') insertHR();
|
||
}
|
||
|
||
|
||
// ===== 표 삽입 모달 HTML 생성 =====
|
||
function createTableModal() {
|
||
const modalHTML = `
|
||
<div class="table-modal" id="tableModal">
|
||
<div class="table-modal-content">
|
||
<div class="table-modal-title">▦ 표 삽입</div>
|
||
<div class="table-modal-row">
|
||
<label>행 수</label>
|
||
<input type="number" id="tableRows" value="3" min="1" max="20">
|
||
</div>
|
||
<div class="table-modal-row">
|
||
<label>열 수</label>
|
||
<input type="number" id="tableCols" value="3" min="1" max="10">
|
||
</div>
|
||
<div class="table-modal-row">
|
||
<label>헤더 행 포함</label>
|
||
<input type="checkbox" id="tableHeader" checked>
|
||
</div>
|
||
<div class="table-modal-buttons">
|
||
<button class="table-modal-btn secondary" onclick="closeTableModal()">취소</button>
|
||
<button class="table-modal-btn primary" onclick="insertTable()">삽입</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
return modalHTML;
|
||
}
|
||
|
||
// ===== 토스트 컨테이너 생성 =====
|
||
function createToastContainer() {
|
||
if (!document.getElementById('toastContainer')) {
|
||
const container = document.createElement('div');
|
||
container.id = 'toastContainer';
|
||
container.className = 'toast-container';
|
||
document.body.appendChild(container);
|
||
}
|
||
}
|
||
|
||
// ===== 토스트 메시지 =====
|
||
function toast(message) {
|
||
createToastContainer();
|
||
const container = document.getElementById('toastContainer');
|
||
const toastEl = document.createElement('div');
|
||
toastEl.className = 'toast';
|
||
toastEl.textContent = message;
|
||
container.appendChild(toastEl);
|
||
setTimeout(() => toastEl.remove(), 3000);
|
||
}
|
||
|
||
// ===== iframe 참조 가져오기 =====
|
||
function getPreviewIframe() {
|
||
return document.getElementById('previewFrame');
|
||
}
|
||
|
||
function getIframeDoc() {
|
||
const iframe = getPreviewIframe();
|
||
if (!iframe) return null;
|
||
return iframe.contentDocument || iframe.contentWindow.document;
|
||
}
|
||
|
||
// ===== 기본 포맷 명령 =====
|
||
function formatText(command, value = null) {
|
||
const doc = getIframeDoc();
|
||
if (!doc || !isEditing) return;
|
||
saveState();
|
||
doc.execCommand(command, false, value);
|
||
}
|
||
|
||
// ===== 자간 조절 =====
|
||
function adjustLetterSpacing(delta) {
|
||
const doc = getIframeDoc();
|
||
if (!doc || !isEditing) return;
|
||
|
||
isApplyingFormat = true;
|
||
const selection = doc.getSelection();
|
||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||
toast('텍스트를 선택해주세요');
|
||
return;
|
||
}
|
||
|
||
saveState();
|
||
const range = selection.getRangeAt(0);
|
||
let targetNode = range.commonAncestorContainer;
|
||
if (targetNode.nodeType === Node.TEXT_NODE) targetNode = targetNode.parentNode;
|
||
|
||
const computed = doc.defaultView.getComputedStyle(targetNode);
|
||
const currentSpacing = parseFloat(computed.letterSpacing) || 0;
|
||
const newSpacing = currentSpacing + delta;
|
||
|
||
if (targetNode.tagName === 'SPAN' && range.toString() === targetNode.textContent) {
|
||
targetNode.style.letterSpacing = newSpacing + 'px';
|
||
} else {
|
||
try {
|
||
const span = doc.createElement('span');
|
||
span.style.letterSpacing = newSpacing + 'px';
|
||
range.surroundContents(span);
|
||
} catch (e) {
|
||
const fragment = range.extractContents();
|
||
const span = doc.createElement('span');
|
||
span.style.letterSpacing = newSpacing + 'px';
|
||
span.appendChild(fragment);
|
||
range.insertNode(span);
|
||
}
|
||
}
|
||
toast('자간: ' + newSpacing.toFixed(1) + 'px');
|
||
setTimeout(() => { isApplyingFormat = false; }, 100);
|
||
}
|
||
|
||
// ===== 색상 적용 =====
|
||
function applyTextColor(color) { formatText('foreColor', color); }
|
||
function applyBgColor(color) { formatText('hiliteColor', color); }
|
||
|
||
// ===== 목록 =====
|
||
function toggleBulletList() { formatText('insertUnorderedList'); }
|
||
function toggleNumberList() { formatText('insertOrderedList'); }
|
||
|
||
// ===== 들여쓰기 =====
|
||
function adjustIndent(direction) {
|
||
const doc = getIframeDoc();
|
||
if (!doc || !isEditing) return;
|
||
|
||
if (activeBlock) {
|
||
saveState();
|
||
const current = parseInt(activeBlock.style.marginLeft) || 0;
|
||
activeBlock.style.marginLeft = Math.max(0, current + (direction * 20)) + 'px';
|
||
toast(direction > 0 ? '→ 들여쓰기' : '← 내어쓰기');
|
||
} else {
|
||
formatText(direction > 0 ? 'indent' : 'outdent');
|
||
}
|
||
}
|
||
|
||
// ===== 제목 스타일 =====
|
||
function applyHeading(tag) {
|
||
const doc = getIframeDoc();
|
||
if (!doc || !isEditing || !activeBlock) return;
|
||
|
||
saveState();
|
||
const content = activeBlock.innerHTML;
|
||
let newEl;
|
||
|
||
if (tag === '') {
|
||
newEl = doc.createElement('p');
|
||
newEl.innerHTML = content;
|
||
newEl.style.fontSize = '12pt';
|
||
newEl.style.lineHeight = '1.6';
|
||
} else {
|
||
newEl = doc.createElement(tag);
|
||
newEl.innerHTML = content;
|
||
if (tag === 'h1') {
|
||
newEl.style.cssText = 'font-size:20pt; font-weight:900; color:#1a365d; border-bottom:2px solid #1a365d; margin-bottom:20px;';
|
||
} else if (tag === 'h2') {
|
||
newEl.style.cssText = 'font-size:18pt; border-left:5px solid #2c5282; padding-left:10px; color:#1a365d;';
|
||
} else if (tag === 'h3') {
|
||
newEl.style.cssText = 'font-size:14pt; color:#2c5282;';
|
||
}
|
||
}
|
||
|
||
newEl.setAttribute('contenteditable', 'true');
|
||
activeBlock.replaceWith(newEl);
|
||
setActiveBlock(newEl);
|
||
}
|
||
|
||
// ===== 폰트 =====
|
||
function applyFontFamily(fontName) {
|
||
if (!isEditing) return;
|
||
formatText('fontName', fontName);
|
||
}
|
||
|
||
function applyFontSizeInput(size) {
|
||
const doc = getIframeDoc();
|
||
if (!doc || !isEditing) return;
|
||
|
||
const selection = doc.getSelection();
|
||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
|
||
|
||
saveState();
|
||
const range = selection.getRangeAt(0);
|
||
try {
|
||
const span = doc.createElement('span');
|
||
span.style.fontSize = size + 'pt';
|
||
range.surroundContents(span);
|
||
} catch (e) {
|
||
const fragment = range.extractContents();
|
||
const span = doc.createElement('span');
|
||
span.style.fontSize = size + 'pt';
|
||
span.appendChild(fragment);
|
||
range.insertNode(span);
|
||
}
|
||
toast('글씨 크기: ' + size + 'pt');
|
||
}
|
||
|
||
// ===== 표 삽입 =====
|
||
function openTableModal() {
|
||
document.getElementById('tableModal').classList.add('active');
|
||
}
|
||
|
||
function closeTableModal() {
|
||
document.getElementById('tableModal').classList.remove('active');
|
||
}
|
||
|
||
function insertTable() {
|
||
const doc = getIframeDoc();
|
||
if (!doc || !isEditing) return;
|
||
|
||
const rows = parseInt(document.getElementById('tableRows').value) || 3;
|
||
const cols = parseInt(document.getElementById('tableCols').value) || 3;
|
||
const hasHeader = document.getElementById('tableHeader').checked;
|
||
|
||
saveState();
|
||
|
||
let tableHTML = '<table style="width:100%; border-collapse:collapse; margin:15px 0; font-size:9.5pt; border-top:2px solid #1a365d;"><tbody>';
|
||
for (let i = 0; i < rows; i++) {
|
||
tableHTML += '<tr>';
|
||
for (let j = 0; j < cols; j++) {
|
||
if (i === 0 && hasHeader) {
|
||
tableHTML += '<th style="border:1px solid #ddd; padding:8px; background:#1a365d; color:#fff; font-weight:700;">헤더</th>';
|
||
} else {
|
||
tableHTML += '<td style="border:1px solid #ddd; padding:8px;">내용</td>';
|
||
}
|
||
}
|
||
tableHTML += '</tr>';
|
||
}
|
||
tableHTML += '</tbody></table>';
|
||
|
||
insertAtCursor(tableHTML);
|
||
closeTableModal();
|
||
toast('▦ 표가 삽입되었습니다');
|
||
}
|
||
|
||
// ===== 이미지 삽입 =====
|
||
function insertImage() {
|
||
const doc = getIframeDoc();
|
||
if (!doc || !isEditing) return;
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'image/*';
|
||
input.onchange = e => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = ev => {
|
||
saveState();
|
||
const html = `<figure style="margin:15px 0; text-align:center;">
|
||
<img src="${ev.target.result}" style="max-width:100%; height:auto; cursor:pointer;" onclick="selectImageForResize(this)">
|
||
<figcaption style="font-size:9pt; color:#666; margin-top:5px;">그림 설명</figcaption>
|
||
</figure>`;
|
||
insertAtCursor(html);
|
||
toast('🖼️ 이미지가 삽입되었습니다');
|
||
};
|
||
reader.readAsDataURL(file);
|
||
};
|
||
input.click();
|
||
}
|
||
|
||
// ===== 이미지 리사이즈 =====
|
||
function selectImageForResize(img) {
|
||
if (!isEditing) return;
|
||
|
||
// 기존 선택 해제
|
||
const doc = getIframeDoc();
|
||
doc.querySelectorAll('img.selected-image').forEach(i => {
|
||
i.classList.remove('selected-image');
|
||
i.style.outline = '';
|
||
});
|
||
|
||
// 새 선택
|
||
img.classList.add('selected-image');
|
||
img.style.outline = '3px solid #00c853';
|
||
|
||
// 크기 조절 핸들러
|
||
img.onmousedown = function(e) {
|
||
if (!isEditing) return;
|
||
e.preventDefault();
|
||
const startX = e.clientX;
|
||
const startWidth = img.offsetWidth;
|
||
|
||
function onMouseMove(e) {
|
||
const diff = e.clientX - startX;
|
||
const newWidth = Math.max(50, startWidth + diff);
|
||
img.style.width = newWidth + 'px';
|
||
img.style.height = 'auto';
|
||
}
|
||
|
||
function onMouseUp() {
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
saveState();
|
||
toast('이미지 크기 조절됨');
|
||
}
|
||
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
};
|
||
}
|
||
|
||
// ===== 구분선 삽입 =====
|
||
function insertHR() {
|
||
const doc = getIframeDoc();
|
||
if (!doc || !isEditing) return;
|
||
saveState();
|
||
insertAtCursor('<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">');
|
||
toast('― 구분선 삽입');
|
||
}
|
||
|
||
// ===== 커서 위치에 HTML 삽입 =====
|
||
function insertAtCursor(html) {
|
||
const doc = getIframeDoc();
|
||
if (!doc) return;
|
||
|
||
const selection = doc.getSelection();
|
||
if (selection && selection.rangeCount > 0) {
|
||
const range = selection.getRangeAt(0);
|
||
range.deleteContents();
|
||
const temp = doc.createElement('div');
|
||
temp.innerHTML = html;
|
||
const frag = doc.createDocumentFragment();
|
||
while (temp.firstChild) frag.appendChild(temp.firstChild);
|
||
range.insertNode(frag);
|
||
} else if (activeBlock) {
|
||
activeBlock.insertAdjacentHTML('afterend', html);
|
||
}
|
||
}
|
||
|
||
// ===== 블록 선택/관리 =====
|
||
function setActiveBlock(el) {
|
||
clearActiveBlock();
|
||
activeBlock = el;
|
||
if (activeBlock) activeBlock.classList.add('active-block');
|
||
}
|
||
|
||
function clearActiveBlock() {
|
||
if (activeBlock) activeBlock.classList.remove('active-block');
|
||
activeBlock = null;
|
||
}
|
||
|
||
// ===== Undo/Redo =====
|
||
function saveState() {
|
||
const doc = getIframeDoc();
|
||
if (!doc) return;
|
||
|
||
if (redoStack.length > 0) redoStack.length = 0;
|
||
historyStack.push(doc.body.innerHTML);
|
||
if (historyStack.length > MAX_HISTORY) historyStack.shift();
|
||
}
|
||
|
||
function performUndo() {
|
||
const doc = getIframeDoc();
|
||
if (!doc || historyStack.length <= 1) return;
|
||
|
||
redoStack.push(doc.body.innerHTML);
|
||
historyStack.pop();
|
||
doc.body.innerHTML = historyStack[historyStack.length - 1];
|
||
bindIframeEditEvents();
|
||
toast('↩️ 실행 취소');
|
||
}
|
||
|
||
function performRedo() {
|
||
const doc = getIframeDoc();
|
||
if (!doc || redoStack.length === 0) return;
|
||
|
||
const nextState = redoStack.pop();
|
||
historyStack.push(nextState);
|
||
doc.body.innerHTML = nextState;
|
||
bindIframeEditEvents();
|
||
toast('↪️ 다시 실행');
|
||
}
|
||
|
||
// ===== 키보드 단축키 =====
|
||
function handleEditorKeydown(e) {
|
||
if (!isEditing) return;
|
||
|
||
if (e.ctrlKey || e.metaKey) {
|
||
switch (e.key.toLowerCase()) {
|
||
case 'b': e.preventDefault(); formatText('bold'); break;
|
||
case 'i': e.preventDefault(); formatText('italic'); break;
|
||
case 'u': e.preventDefault(); formatText('underline'); break;
|
||
case 'z': e.preventDefault(); e.shiftKey ? performRedo() : performUndo(); break;
|
||
case 'y': e.preventDefault(); performRedo(); break;
|
||
case '=':
|
||
case '+': e.preventDefault(); adjustLetterSpacing(0.5); break;
|
||
case '-': e.preventDefault(); adjustLetterSpacing(-0.5); break;
|
||
}
|
||
}
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
adjustIndent(e.shiftKey ? -1 : 1);
|
||
}
|
||
}
|
||
|
||
|
||
// ===== 리사이즈 핸들 추가 함수 =====
|
||
function addResizeHandle(doc, element, type) {
|
||
// wrapper 생성
|
||
const wrapper = doc.createElement('div');
|
||
wrapper.className = 'resizable-container ' + (type === 'table' ? 'table-resize block-type' : 'figure-resize');
|
||
|
||
// 초기 크기 설정
|
||
const rect = element.getBoundingClientRect();
|
||
wrapper.style.width = element.style.width || (rect.width + 'px');
|
||
|
||
// 크기 표시 툴팁
|
||
const tooltip = doc.createElement('div');
|
||
tooltip.className = 'size-tooltip';
|
||
tooltip.textContent = Math.round(rect.width) + ' × ' + Math.round(rect.height);
|
||
|
||
// 리사이즈 핸들
|
||
const handle = doc.createElement('div');
|
||
handle.className = 'resize-handle';
|
||
handle.title = '드래그하여 크기 조절';
|
||
|
||
// DOM 구조 변경
|
||
element.parentNode.insertBefore(wrapper, element);
|
||
wrapper.appendChild(element);
|
||
wrapper.appendChild(tooltip);
|
||
wrapper.appendChild(handle);
|
||
|
||
// 표는 width 100%로 시작
|
||
if (type === 'table') {
|
||
element.style.width = '100%';
|
||
}
|
||
|
||
// 리사이즈 이벤트
|
||
let isResizing = false;
|
||
let startX, startY, startWidth, startHeight;
|
||
|
||
handle.addEventListener('mousedown', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
isResizing = true;
|
||
wrapper.classList.add('resizing');
|
||
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
startWidth = wrapper.offsetWidth;
|
||
startHeight = wrapper.offsetHeight;
|
||
|
||
doc.addEventListener('mousemove', onMouseMove);
|
||
doc.addEventListener('mouseup', onMouseUp);
|
||
});
|
||
|
||
function onMouseMove(e) {
|
||
if (!isResizing) return;
|
||
e.preventDefault();
|
||
|
||
const deltaX = e.clientX - startX;
|
||
const deltaY = e.clientY - startY;
|
||
|
||
const aspectRatio = startWidth / startHeight;
|
||
let newWidth = Math.max(100, startWidth + deltaX);
|
||
let newHeight;
|
||
|
||
if (e.shiftKey) {
|
||
newHeight = newWidth / aspectRatio; // 비율 유지
|
||
} else {
|
||
newHeight = Math.max(50, startHeight + deltaY);
|
||
}
|
||
|
||
wrapper.style.width = newWidth + 'px';
|
||
|
||
// 이미지인 경우 width, height 둘 다 조절
|
||
if (type !== 'table') {
|
||
const img = wrapper.querySelector('img');
|
||
if (img) {
|
||
img.style.width = newWidth + 'px';
|
||
img.style.height = newHeight + 'px';
|
||
img.style.maxWidth = 'none';
|
||
img.style.maxHeight = 'none';
|
||
}
|
||
}
|
||
|
||
tooltip.textContent = Math.round(newWidth) + ' × ' + Math.round(newHeight);
|
||
}
|
||
|
||
function onMouseUp(e) {
|
||
if (!isResizing) return;
|
||
isResizing = false;
|
||
wrapper.classList.remove('resizing');
|
||
|
||
doc.removeEventListener('mousemove', onMouseMove);
|
||
doc.removeEventListener('mouseup', onMouseUp);
|
||
|
||
saveState();
|
||
toast('📐 크기 조절: ' + Math.round(wrapper.offsetWidth) + 'px');
|
||
}
|
||
}
|
||
|
||
// ===== iframe 내부에 편집용 스타일 주입 =====
|
||
function injectEditStyles(doc) {
|
||
if (doc.getElementById('editor-inject-style')) return;
|
||
|
||
const style = doc.createElement('style');
|
||
style.id = 'editor-inject-style';
|
||
style.textContent = `
|
||
/* 리사이즈 컨테이너 */
|
||
.resizable-container { position: relative; display: inline-block; max-width: 100%; }
|
||
.resizable-container.block-type { display: block; }
|
||
|
||
/* 리사이즈 핸들 */
|
||
.resize-handle {
|
||
position: absolute;
|
||
right: -2px;
|
||
bottom: -2px;
|
||
width: 18px;
|
||
height: 18px;
|
||
background: #00C853;
|
||
cursor: se-resize;
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
z-index: 100;
|
||
border-radius: 3px 0 3px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.resize-handle::after {
|
||
content: '⤡';
|
||
color: white;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
.resizable-container:hover .resize-handle { opacity: 0.8; }
|
||
.resize-handle:hover { opacity: 1 !important; transform: scale(1.1); }
|
||
.resizable-container.resizing { outline: 2px dashed #00C853 !important; }
|
||
.resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; }
|
||
|
||
/* 표 전용 - 파란색 핸들 */
|
||
.resizable-container.table-resize .resize-handle { background: #2196F3; }
|
||
.resizable-container.table-resize.resizing .resize-handle { background: #FF5722; }
|
||
|
||
/* 이미지 전용 */
|
||
.resizable-container.figure-resize img { display: block; }
|
||
|
||
/* 크기 표시 툴팁 */
|
||
.size-tooltip {
|
||
position: absolute;
|
||
top: -25px;
|
||
right: 0;
|
||
background: rgba(0,0,0,0.8);
|
||
color: white;
|
||
padding: 2px 8px;
|
||
border-radius: 3px;
|
||
font-size: 11px;
|
||
white-space: nowrap;
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
pointer-events: none;
|
||
}
|
||
.resizable-container:hover .size-tooltip,
|
||
.resizable-container.resizing .size-tooltip { opacity: 1; }
|
||
|
||
/* 열 리사이즈 핸들 */
|
||
.col-resize-handle {
|
||
position: absolute;
|
||
top: 0;
|
||
width: 6px;
|
||
height: 100%;
|
||
background: transparent;
|
||
cursor: col-resize;
|
||
z-index: 50;
|
||
}
|
||
.col-resize-handle:hover { background: rgba(33, 150, 243, 0.3); }
|
||
.col-resize-handle.dragging { background: rgba(33, 150, 243, 0.5); }
|
||
|
||
/* 편집 중 하이라이트 */
|
||
[contenteditable]:focus { outline: 2px solid #00C853 !important; }
|
||
[contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); }
|
||
`;
|
||
doc.head.appendChild(style);
|
||
}
|
||
|
||
// ===== iframe 편집 이벤트 바인딩 =====
|
||
// ===== iframe 편집 이벤트 바인딩 =====
|
||
function bindIframeEditEvents() {
|
||
const doc = getIframeDoc();
|
||
if (!doc) return;
|
||
|
||
// 편집용 스타일 주입
|
||
injectEditStyles(doc);
|
||
|
||
// 키보드 이벤트
|
||
doc.removeEventListener('keydown', handleEditorKeydown);
|
||
doc.addEventListener('keydown', handleEditorKeydown);
|
||
|
||
// 블록 클릭 이벤트
|
||
doc.body.addEventListener('click', function(e) {
|
||
if (!isEditing) return;
|
||
let target = e.target;
|
||
while (target && target !== doc.body) {
|
||
if (['DIV', 'P', 'H1', 'H2', 'H3', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(target.tagName)) {
|
||
setActiveBlock(target);
|
||
return;
|
||
}
|
||
target = target.parentElement;
|
||
}
|
||
clearActiveBlock();
|
||
});
|
||
|
||
// ===== 표에 리사이즈 핸들 추가 =====
|
||
doc.querySelectorAll('.body-content table, .sheet table').forEach(table => {
|
||
if (table.closest('.resizable-container')) return;
|
||
addResizeHandle(doc, table, 'table');
|
||
addColumnResizeHandles(doc, table); // 열 리사이즈 추가
|
||
});
|
||
|
||
// ===== 이미지에 리사이즈 핸들 추가 =====
|
||
doc.querySelectorAll('figure img, .body-content img, .sheet img').forEach(img => {
|
||
if (img.closest('.resizable-container')) return;
|
||
addResizeHandle(doc, img, 'image');
|
||
});
|
||
}
|
||
// ===== 표 열 리사이즈 핸들 추가 =====
|
||
function addColumnResizeHandles(doc, table) {
|
||
// 테이블에 position relative 설정
|
||
table.style.position = 'relative';
|
||
|
||
// 첫 번째 행의 셀들을 기준으로 열 핸들 생성
|
||
const firstRow = table.querySelector('tr');
|
||
if (!firstRow) return;
|
||
|
||
const cells = firstRow.querySelectorAll('th, td');
|
||
|
||
cells.forEach((cell, index) => {
|
||
if (index === cells.length - 1) return; // 마지막 열은 제외
|
||
|
||
// 이미 핸들이 있으면 스킵
|
||
if (cell.querySelector('.col-resize-handle')) return;
|
||
|
||
cell.style.position = 'relative';
|
||
|
||
const handle = doc.createElement('div');
|
||
handle.className = 'col-resize-handle';
|
||
handle.style.right = '-3px';
|
||
cell.appendChild(handle);
|
||
|
||
let startX, startWidth, nextStartWidth;
|
||
let nextCell = cells[index + 1];
|
||
|
||
handle.addEventListener('mousedown', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
handle.classList.add('dragging');
|
||
startX = e.clientX;
|
||
startWidth = cell.offsetWidth;
|
||
nextStartWidth = nextCell ? nextCell.offsetWidth : 0;
|
||
|
||
doc.addEventListener('mousemove', onMouseMove);
|
||
doc.addEventListener('mouseup', onMouseUp);
|
||
});
|
||
|
||
function onMouseMove(e) {
|
||
const delta = e.clientX - startX;
|
||
const newWidth = Math.max(30, startWidth + delta);
|
||
|
||
cell.style.width = newWidth + 'px';
|
||
|
||
// 다음 열도 조정 (테이블 전체 너비 유지)
|
||
if (nextCell && nextStartWidth > 30) {
|
||
const newNextWidth = Math.max(30, nextStartWidth - delta);
|
||
nextCell.style.width = newNextWidth + 'px';
|
||
}
|
||
}
|
||
|
||
function onMouseUp() {
|
||
handle.classList.remove('dragging');
|
||
doc.removeEventListener('mousemove', onMouseMove);
|
||
doc.removeEventListener('mouseup', onMouseUp);
|
||
saveState();
|
||
toast('📊 열 너비 조절됨');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===== 편집 모드 토글 =====
|
||
function toggleEditMode() {
|
||
const doc = getIframeDoc();
|
||
if (!doc) return;
|
||
|
||
isEditing = !isEditing;
|
||
|
||
const formatBar = document.getElementById('formatBar');
|
||
const editBtn = document.getElementById('editModeBtn');
|
||
|
||
if (isEditing) {
|
||
// 편집 모드 ON
|
||
doc.designMode = 'on';
|
||
if (formatBar) formatBar.classList.add('active');
|
||
if (editBtn) {
|
||
editBtn.textContent = '✏️ 편집 중';
|
||
editBtn.classList.add('active');
|
||
}
|
||
|
||
// contenteditable 설정
|
||
doc.querySelectorAll('.sheet *').forEach(el => {
|
||
if (['DIV', 'P', 'H1', 'H2', 'H3', 'SPAN', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(el.tagName)) {
|
||
el.setAttribute('contenteditable', 'true');
|
||
}
|
||
});
|
||
|
||
bindIframeEditEvents();
|
||
saveState();
|
||
toast('✏️ 편집 모드 시작');
|
||
} else {
|
||
// 편집 모드 OFF
|
||
doc.designMode = 'off';
|
||
if (formatBar) formatBar.classList.remove('active');
|
||
if (editBtn) {
|
||
editBtn.textContent = '✏️ 편집하기';
|
||
editBtn.classList.remove('active');
|
||
}
|
||
|
||
// contenteditable 제거
|
||
doc.querySelectorAll('[contenteditable]').forEach(el => {
|
||
el.removeAttribute('contenteditable');
|
||
});
|
||
|
||
clearActiveBlock();
|
||
toast('✏️ 편집 모드 종료');
|
||
}
|
||
}
|
||
|
||
// ===== 편집기 초기화 =====
|
||
function initEditor() {
|
||
// 편집 바가 없으면 생성
|
||
if (!document.getElementById('formatBar')) {
|
||
const previewContainer = document.querySelector('.main');
|
||
if (previewContainer) {
|
||
previewContainer.insertAdjacentHTML('afterbegin', createFormatBar());
|
||
}
|
||
}
|
||
|
||
// 표 모달이 없으면 생성
|
||
if (!document.getElementById('tableModal')) {
|
||
document.body.insertAdjacentHTML('beforeend', createTableModal());
|
||
}
|
||
|
||
// 토스트 컨테이너 생성
|
||
createToastContainer();
|
||
|
||
console.log('Editor initialized');
|
||
}
|
||
|
||
// ===== 지능형 정렬 =====
|
||
function smartAlign() {
|
||
const doc = getIframeDoc();
|
||
if (!doc) {
|
||
toast('⚠️ 문서가 로드되지 않았습니다');
|
||
return;
|
||
}
|
||
|
||
// ===== 현재 스크롤 위치 저장 =====
|
||
const iframe = getPreviewIframe();
|
||
const scrollY = iframe?.contentWindow?.scrollY || 0;
|
||
|
||
const sheets = Array.from(doc.querySelectorAll('.sheet'));
|
||
if (sheets.length < 2) {
|
||
toast('⚠️ 정렬할 본문 페이지가 없습니다');
|
||
return;
|
||
}
|
||
|
||
toast('지능형 정렬 실행 중...');
|
||
|
||
setTimeout(() => {
|
||
try {
|
||
// 1. 표지 유지
|
||
const coverSheet = sheets[0];
|
||
|
||
// 2. 보고서 제목 추출
|
||
let reportTitle = "보고서";
|
||
const existingTitle = sheets[1]?.querySelector('.rpt-title, .header-title');
|
||
if (existingTitle) reportTitle = existingTitle.innerText;
|
||
|
||
// 3. 콘텐츠 수집 (표지 제외)
|
||
const contentSheets = sheets.slice(1);
|
||
let allNodes = [];
|
||
|
||
contentSheets.forEach(sheet => {
|
||
const body = sheet.querySelector('.body-content');
|
||
if (body) {
|
||
Array.from(body.children).forEach(child => {
|
||
if (child.classList.contains('add-after-btn') ||
|
||
child.classList.contains('delete-block-btn') ||
|
||
child.classList.contains('empty-placeholder')) return;
|
||
|
||
if (['P', 'DIV', 'SPAN'].includes(child.tagName) &&
|
||
child.innerText.trim() === '' &&
|
||
!child.querySelector('img, table, figure')) return;
|
||
|
||
allNodes.push(child);
|
||
});
|
||
}
|
||
sheet.remove();
|
||
});
|
||
|
||
// 4. 설정값
|
||
const MAX_HEIGHT = 970;
|
||
const HEADING_RESERVE = 90;
|
||
let currentHeaderTitle = "목차";
|
||
let pageNum = 1;
|
||
|
||
// 5. 새 페이지 생성 함수
|
||
function createNewPage(headerText) {
|
||
const newSheet = doc.createElement('div');
|
||
newSheet.className = 'sheet';
|
||
newSheet.innerHTML = `
|
||
<div class="page-header">${headerText}</div>
|
||
<div class="body-content"></div>
|
||
<div class="page-footer">
|
||
<span class="rpt-title">${reportTitle}</span>
|
||
<span class="pg-num">- ${pageNum++} -</span>
|
||
</div>`;
|
||
doc.body.appendChild(newSheet);
|
||
return newSheet;
|
||
}
|
||
|
||
// 6. 페이지 재구성
|
||
let currentPage = createNewPage(currentHeaderTitle);
|
||
let currentBody = currentPage.querySelector('.body-content');
|
||
|
||
allNodes.forEach(node => {
|
||
// 강제 페이지 브레이크
|
||
if (node.classList && node.classList.contains('page-break-forced')) {
|
||
currentPage = createNewPage(currentHeaderTitle);
|
||
currentBody = currentPage.querySelector('.body-content');
|
||
currentBody.appendChild(node);
|
||
return;
|
||
}
|
||
|
||
// H1: 새 섹션 시작
|
||
if (node.tagName === 'H1') {
|
||
currentHeaderTitle = node.innerText.split('-')[0].trim();
|
||
if (currentBody.children.length > 0) {
|
||
currentPage = createNewPage(currentHeaderTitle);
|
||
currentBody = currentPage.querySelector('.body-content');
|
||
} else {
|
||
currentPage.querySelector('.page-header').innerText = currentHeaderTitle;
|
||
}
|
||
}
|
||
|
||
// H2, H3: 남은 공간 부족하면 새 페이지
|
||
if (['H2', 'H3'].includes(node.tagName)) {
|
||
const spaceLeft = MAX_HEIGHT - currentBody.scrollHeight;
|
||
if (spaceLeft < HEADING_RESERVE) {
|
||
currentPage = createNewPage(currentHeaderTitle);
|
||
currentBody = currentPage.querySelector('.body-content');
|
||
}
|
||
}
|
||
|
||
// 노드 추가
|
||
currentBody.appendChild(node);
|
||
|
||
// 전 페이지로 강제 이동 설정된 경우 스킵
|
||
if (node.classList && node.classList.contains('move-to-prev-page')) {
|
||
return;
|
||
}
|
||
|
||
// 높이 초과 시 새 페이지로 이동
|
||
if (currentBody.scrollHeight > MAX_HEIGHT) {
|
||
currentBody.removeChild(node);
|
||
currentPage = createNewPage(currentHeaderTitle);
|
||
currentBody = currentPage.querySelector('.body-content');
|
||
currentBody.appendChild(node);
|
||
}
|
||
});
|
||
|
||
// 7. 편집 모드였으면 복원
|
||
if (isEditing) {
|
||
bindIframeEditEvents();
|
||
}
|
||
|
||
// 8. generatedHTML 업데이트 (전역 변수)
|
||
if (typeof generatedHTML !== 'undefined') {
|
||
generatedHTML = '<!DOCTYPE html>' + doc.documentElement.outerHTML;
|
||
}
|
||
|
||
// ===== 스크롤 위치 복원 =====
|
||
setTimeout(() => {
|
||
if (iframe?.contentWindow) {
|
||
iframe.contentWindow.scrollTo(0, scrollY);
|
||
}
|
||
}, 50);
|
||
|
||
toast('✅ 지능형 정렬 완료 (' + pageNum + '페이지)');
|
||
|
||
|
||
} catch (e) {
|
||
console.error('smartAlign 오류:', e);
|
||
toast('❌ 정렬 중 오류: ' + e.message);
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
// ===== 새페이지 시작 =====
|
||
function forcePageBreak() {
|
||
const doc = getIframeDoc();
|
||
if (!doc) {
|
||
toast('⚠️ 문서가 로드되지 않았습니다');
|
||
return;
|
||
}
|
||
|
||
const selection = doc.getSelection();
|
||
if (!selection || !selection.anchorNode) {
|
||
toast('⚠️ 분리할 위치를 클릭하세요');
|
||
return;
|
||
}
|
||
|
||
let targetEl = selection.anchorNode.nodeType === 1
|
||
? selection.anchorNode
|
||
: selection.anchorNode.parentElement;
|
||
|
||
while (targetEl && targetEl.parentElement) {
|
||
if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) {
|
||
break;
|
||
}
|
||
targetEl = targetEl.parentElement;
|
||
}
|
||
|
||
if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) {
|
||
toast('⚠️ 본문 블록을 먼저 클릭하세요');
|
||
return;
|
||
}
|
||
|
||
saveState();
|
||
|
||
const currentBody = targetEl.parentElement;
|
||
const currentSheet = currentBody.closest('.sheet');
|
||
const sheets = Array.from(doc.querySelectorAll('.sheet'));
|
||
const currentIndex = sheets.indexOf(currentSheet);
|
||
|
||
// 클릭한 요소부터 끝까지 수집
|
||
const elementsToMove = [];
|
||
let sibling = targetEl;
|
||
while (sibling) {
|
||
elementsToMove.push(sibling);
|
||
sibling = sibling.nextElementSibling;
|
||
}
|
||
|
||
if (elementsToMove.length === 0) {
|
||
toast('⚠️ 이동할 내용이 없습니다');
|
||
return;
|
||
}
|
||
|
||
// 다음 페이지 찾기
|
||
let nextSheet = sheets[currentIndex + 1];
|
||
let nextBody;
|
||
|
||
if (!nextSheet || !nextSheet.querySelector('.body-content')) {
|
||
const oldHeader = currentSheet.querySelector('.page-header');
|
||
const oldFooter = currentSheet.querySelector('.page-footer');
|
||
nextSheet = doc.createElement('div');
|
||
nextSheet.className = 'sheet';
|
||
nextSheet.innerHTML = `
|
||
<div class="page-header">${oldHeader ? oldHeader.innerText : ''}</div>
|
||
<div class="body-content"></div>
|
||
<div class="page-footer">
|
||
<span class="rpt-title">${oldFooter?.querySelector('.rpt-title')?.innerText || ''}</span>
|
||
<span class="pg-num">- - -</span>
|
||
</div>`;
|
||
currentSheet.after(nextSheet);
|
||
}
|
||
|
||
nextBody = nextSheet.querySelector('.body-content');
|
||
|
||
// 역순으로 맨 앞에 삽입 (순서 유지)
|
||
for (let i = elementsToMove.length - 1; i >= 0; i--) {
|
||
nextBody.insertBefore(elementsToMove[i], nextBody.firstChild);
|
||
}
|
||
|
||
// 첫 번째 요소에 페이지 브레이크 마커 추가 (나중에 지능형 정렬이 존중함)
|
||
targetEl.classList.add('page-break-forced');
|
||
|
||
// 페이지 번호만 재정렬 (smartAlign 호출 안 함!)
|
||
renumberPages(doc);
|
||
|
||
toast('✅ 다음 페이지로 이동됨');
|
||
}
|
||
|
||
|
||
// ===== 전페이지로 이동 (즉시 적용) =====
|
||
function moveToPrevPage() {
|
||
const doc = getIframeDoc();
|
||
if (!doc) {
|
||
toast('⚠️ 문서가 로드되지 않았습니다');
|
||
return;
|
||
}
|
||
|
||
const selection = doc.getSelection();
|
||
if (!selection || !selection.anchorNode) {
|
||
toast('⚠️ 이동할 블록을 클릭하세요');
|
||
return;
|
||
}
|
||
|
||
// 현재 선택된 요소에서 body-content 직계 자식 찾기
|
||
let targetEl = selection.anchorNode.nodeType === 1
|
||
? selection.anchorNode
|
||
: selection.anchorNode.parentElement;
|
||
|
||
while (targetEl && targetEl.parentElement) {
|
||
if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) {
|
||
break;
|
||
}
|
||
targetEl = targetEl.parentElement;
|
||
}
|
||
|
||
if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) {
|
||
toast('⚠️ 본문 블록을 먼저 클릭하세요');
|
||
return;
|
||
}
|
||
|
||
saveState();
|
||
|
||
// 현재 sheet 찾기
|
||
const currentSheet = targetEl.closest('.sheet');
|
||
const sheets = Array.from(doc.querySelectorAll('.sheet'));
|
||
const currentIndex = sheets.indexOf(currentSheet);
|
||
|
||
// 이전 페이지 찾기 (표지 제외)
|
||
if (currentIndex <= 1) {
|
||
toast('⚠️ 이전 페이지가 없습니다');
|
||
return;
|
||
}
|
||
|
||
const prevSheet = sheets[currentIndex - 1];
|
||
const prevBody = prevSheet.querySelector('.body-content');
|
||
|
||
if (!prevBody) {
|
||
toast('⚠️ 이전 페이지에 본문 영역이 없습니다');
|
||
return;
|
||
}
|
||
|
||
// 요소를 이전 페이지 맨 아래로 이동
|
||
prevBody.appendChild(targetEl);
|
||
|
||
// 현재 페이지가 비었으면 삭제
|
||
const currentBody = currentSheet.querySelector('.body-content');
|
||
if (currentBody && currentBody.children.length === 0) {
|
||
currentSheet.remove();
|
||
}
|
||
|
||
// 페이지 번호 재정렬
|
||
renumberPages(doc);
|
||
|
||
toast('✅ 전 페이지로 이동됨');
|
||
}
|
||
|
||
// ===== 페이지 번호 재정렬 =====
|
||
function renumberPages(doc) {
|
||
const sheets = doc.querySelectorAll('.sheet');
|
||
let pageNum = 1;
|
||
|
||
sheets.forEach((sheet, idx) => {
|
||
if (idx === 0) return; // 표지는 번호 없음
|
||
|
||
const pgNum = sheet.querySelector('.pg-num');
|
||
if (pgNum) {
|
||
pgNum.innerText = `- ${pageNum++} -`;
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
|
||
|
||
// DOM 로드 시 초기화
|
||
document.addEventListener('DOMContentLoaded', initEditor);
|
||
|
||
|
||
|
||
|