v2: 글벗 기획안_20260121
This commit is contained in:
70
app.py
70
app.py
@@ -359,6 +359,76 @@ def refine():
|
||||
return jsonify({'error': f'서버 오류: {str(e)}'}), 500
|
||||
|
||||
|
||||
@app.route('/refine-selection', methods=['POST'])
|
||||
def refine_selection():
|
||||
"""선택된 부분만 수정"""
|
||||
try:
|
||||
data = request.json
|
||||
current_html = data.get('current_html', '')
|
||||
selected_text = data.get('selected_text', '')
|
||||
user_request = data.get('request', '')
|
||||
|
||||
if not current_html or not selected_text or not user_request:
|
||||
return jsonify({'error': '필수 데이터가 없습니다.'}), 400
|
||||
|
||||
# Claude API 호출
|
||||
message = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=8000,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content" : f"""HTML 문서에서 지정된 부분만 수정해주세요.
|
||||
|
||||
## 전체 문서 (컨텍스트 파악용)
|
||||
{current_html}
|
||||
|
||||
## 수정 대상 텍스트
|
||||
"{selected_text}"
|
||||
|
||||
## 수정 요청
|
||||
{user_request}
|
||||
|
||||
## 규칙
|
||||
1. 요청을 분석하여 수정 유형을 판단:
|
||||
- TEXT: 텍스트 내용만 수정 (요약, 문장 변경, 단어 수정, 번역 등)
|
||||
- STRUCTURE: HTML 구조 변경 필요 (표 생성, 박스 추가, 레이아웃 변경 등)
|
||||
|
||||
2. 반드시 다음 형식으로만 출력:
|
||||
|
||||
TYPE: (TEXT 또는 STRUCTURE)
|
||||
CONTENT:
|
||||
(수정된 내용)
|
||||
|
||||
3. TEXT인 경우: 순수 텍스트만 출력 (HTML 태그 없이)
|
||||
4. STRUCTURE인 경우: 완전한 HTML 요소 출력 (기존 클래스명 유지)
|
||||
5. 개조식 문체 유지 (~임, ~함, ~필요)
|
||||
"""
|
||||
}]
|
||||
)
|
||||
|
||||
result = message.content[0].text
|
||||
result = result.replace('```html', '').replace('```', '').strip()
|
||||
|
||||
# TYPE과 CONTENT 파싱
|
||||
edit_type = 'TEXT'
|
||||
content = result
|
||||
|
||||
if 'TYPE:' in result and 'CONTENT:' in result:
|
||||
type_line = result.split('CONTENT:')[0]
|
||||
if 'STRUCTURE' in type_line:
|
||||
edit_type = 'STRUCTURE'
|
||||
content = result.split('CONTENT:')[1].strip()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'type': edit_type,
|
||||
'html': content
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/download/html', methods=['POST'])
|
||||
def download_html():
|
||||
"""HTML 파일 다운로드"""
|
||||
|
||||
205
static/css/editor.css
Normal file
205
static/css/editor.css
Normal file
@@ -0,0 +1,205 @@
|
||||
/* ===== 편집 바 스타일 ===== */
|
||||
.format-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--ui-panel);
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.format-bar.active { display: flex; }
|
||||
|
||||
.format-btn {
|
||||
padding: 6px 10px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ui-text);
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.format-btn:hover { background: var(--ui-hover); }
|
||||
.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); }
|
||||
|
||||
.format-select {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ui-bg);
|
||||
color: var(--ui-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.format-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--ui-border);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
/* 툴팁 */
|
||||
.format-btn .tooltip {
|
||||
position: absolute;
|
||||
bottom: -28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.format-btn:hover .tooltip { opacity: 1; }
|
||||
|
||||
/* 색상 선택기 */
|
||||
.color-picker-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.color-picker-btn input[type="color"] {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 편집 모드 활성 블록 */
|
||||
.active-block {
|
||||
outline: 2px dashed var(--ui-accent) !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 표 삽입 모달 */
|
||||
.table-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 2000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.table-modal.active { display: flex; }
|
||||
|
||||
.table-modal-content {
|
||||
background: var(--ui-panel);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 320px;
|
||||
border: 1px solid var(--ui-border);
|
||||
}
|
||||
|
||||
.table-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--ui-text);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table-modal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-modal-row label {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--ui-dim);
|
||||
}
|
||||
|
||||
.table-modal-row input[type="number"] {
|
||||
width: 60px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ui-bg);
|
||||
color: var(--ui-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-modal-row input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.table-modal-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.table-modal-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-modal-btn.primary {
|
||||
background: var(--ui-accent);
|
||||
color: #003300;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-modal-btn.secondary {
|
||||
background: var(--ui-border);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
/* 토스트 메시지 */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes toastOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 인쇄 시 숨김 */
|
||||
@media print {
|
||||
.format-bar,
|
||||
.table-modal,
|
||||
.toast-container {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
554
static/js/editor.js
Normal file
554
static/js/editor.js
Normal file
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* 글벗 Light - 편집 바 모듈
|
||||
* editor.js
|
||||
*/
|
||||
|
||||
// ===== 전역 변수 =====
|
||||
let isEditing = false;
|
||||
let activeBlock = null;
|
||||
let historyStack = [];
|
||||
let redoStack = [];
|
||||
const MAX_HISTORY = 50;
|
||||
let isApplyingFormat = false;
|
||||
|
||||
// ===== 편집 바 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>
|
||||
<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" id="btnBold" onclick="formatText('bold')"><b>B</b><span class="tooltip">굵게 (Ctrl+B)</span></button>
|
||||
<button class="format-btn" id="btnItalic" onclick="formatText('italic')"><i>I</i><span class="tooltip">기울임 (Ctrl+I)</span></button>
|
||||
<button class="format-btn" id="btnUnderline" onclick="formatText('underline')"><u>U</u><span class="tooltip">밑줄 (Ctrl+U)</span></button>
|
||||
<button class="format-btn" id="btnStrike" onclick="formatText('strikeThrough')"><s>S</s><span class="tooltip">취소선</span></button>
|
||||
<div class="format-divider"></div>
|
||||
<button class="format-btn" onclick="adjustLetterSpacing(-0.5)">A⇠<span class="tooltip">자간 줄이기</span></button>
|
||||
<button class="format-btn" onclick="adjustLetterSpacing(0.5)">A⇢<span class="tooltip">자간 늘리기</span></button>
|
||||
<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)">
|
||||
<span class="tooltip">글자 색상</span>
|
||||
</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)">
|
||||
<span class="tooltip">배경 색상</span>
|
||||
</div>
|
||||
<div class="format-divider"></div>
|
||||
<button class="format-btn" onclick="formatText('justifyLeft')">⫷<span class="tooltip">왼쪽 정렬</span></button>
|
||||
<button class="format-btn" onclick="formatText('justifyCenter')">☰<span class="tooltip">가운데 정렬</span></button>
|
||||
<button class="format-btn" onclick="formatText('justifyRight')">⫸<span class="tooltip">오른쪽 정렬</span></button>
|
||||
<div class="format-divider"></div>
|
||||
<button class="format-btn" onclick="toggleBulletList()">•≡<span class="tooltip">글머리 기호</span></button>
|
||||
<button class="format-btn" onclick="toggleNumberList()">1.<span class="tooltip">번호 목록</span></button>
|
||||
<button class="format-btn" onclick="adjustIndent(-1)">⇤<span class="tooltip">내어쓰기</span></button>
|
||||
<button class="format-btn" onclick="adjustIndent(1)">⇥<span class="tooltip">들여쓰기</span></button>
|
||||
<div class="format-divider"></div>
|
||||
<button class="format-btn" onclick="openTableModal()">▦<span class="tooltip">표 삽입</span></button>
|
||||
<button class="format-btn" onclick="insertImage()">🖼️<span class="tooltip">그림 삽입</span></button>
|
||||
<button class="format-btn" onclick="insertHR()">―<span class="tooltip">구분선</span></button>
|
||||
<div class="format-divider"></div>
|
||||
<select class="format-select" onchange="applyHeading(this.value)" style="min-width:100px;">
|
||||
<option value="">본문</option>
|
||||
<option value="h1">제목 1</option>
|
||||
<option value="h2">제목 2</option>
|
||||
<option value="h3">제목 3</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
return formatBarHTML;
|
||||
}
|
||||
|
||||
// ===== 표 삽입 모달 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== iframe 편집 이벤트 바인딩 =====
|
||||
function bindIframeEditEvents() {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc) return;
|
||||
|
||||
// 키보드 이벤트
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 편집 모드 토글 =====
|
||||
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('.preview-container');
|
||||
if (previewContainer) {
|
||||
previewContainer.insertAdjacentHTML('afterbegin', createFormatBar());
|
||||
}
|
||||
}
|
||||
|
||||
// 표 모달이 없으면 생성
|
||||
if (!document.getElementById('tableModal')) {
|
||||
document.body.insertAdjacentHTML('beforeend', createTableModal());
|
||||
}
|
||||
|
||||
// 토스트 컨테이너 생성
|
||||
createToastContainer();
|
||||
|
||||
console.log('Editor initialized');
|
||||
}
|
||||
|
||||
// DOM 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', initEditor);
|
||||
2294
templates/index.html
2294
templates/index.html
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user