Files
test/static/js/editor.js
2026-02-20 11:34:02 +09:00

1209 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 글벗 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);