Files
AriWeb-Suite/modules/webmaker/public/visual-editor-ui.js
T

248 lines
26 KiB
JavaScript
Raw Normal View History

'use strict';
/**
* public/visual-editor-ui.js — v1.8.0 직접 편집(시각 편집기) 빌더 쪽 제어. 오른쪽 미리보기 도구 줄의 "직접 편집" 버튼으로 켜고 끈다.
* 역할
* - 켜기: GET /api/sites/<id>/visual/<페이지> 로 캔버스 문서를 받아 미리보기 iframe(sandbox, 출처 null)의 srcdoc 에 넣는다. 세션·baseHash·rev 를 기억한다.
* - 캔버스와의 연결 규약: 캔버스의 첫 'ready' 창 메시지(iframe 창·출처 'null'·세션·종류 확인, MessagePort 동봉)로 한 번 연결한 뒤 모든 명령·이벤트는 그 포트로만(창 메시지는 더 받지 않는다).
* 캔버스가 보낸 변경(본문)을 실행 취소 기록에 쌓고 저장 상태를 바꾼다.
* - 저장(버튼·Ctrl/Cmd+S): PUT { body, baseHash, rev } → 파일 갱신·버전 기록. 409(다른 창이 먼저 저장) 는 안내와 함께 "다시 불러오기" 로 이어진다.
* - 실행 취소/다시 실행(스냅샷 기록), 버리고 다시 불러오기, 편집 끝내기. 저장하지 않은 변경은 페이지·사이트·탭 전환과 창 닫기 전에 묻는다(app.js 의 discardOk·beforeunload 가 이 모듈에 묻는다).
* - 팔레트(구성 요소: 끌어다 놓기 + 버튼으로 "선택한 요소 뒤에 추가")와 속성 창(글·링크·이미지·정렬·색·배경·여백 + 상위 선택·이동·복제·삭제 버튼).
* app.js 의 전역(current·api·ed·setEditorOpen·renderGit·loadFrame·logLine·pushMsg·$)을 쓴다. 직접 편집은 AI 캐시를 쓰지 않고 AI 수정으로 세지 않는다(서버 규칙).
*/
window.VisualEditor = (() => {
const core = window.WMVisualCore; if (!core) return null;
const st = { active: false, siteId: null, file: null, session: null, port: null, baseHash: null, rev: null, history: core.createHistory(200), save: core.createSaveState(), savedBody: null, selected: null, loading: false };
const frame = () => $('frame');
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// ---------- 캔버스와의 통신(연결 규약, 1.8.0 검토 수정) ----------
// 캔버스 런타임은 부팅 때 **한 번** 창 메시지 'ready' 에 MessagePort 를 동봉해 보낸다. 여기서는 보낸 창(iframe contentWindow)·출처('null')·세션·종류를 검사한 뒤 그 포트에 연결하고,
// 그 뒤 모든 명령은 포트로만 보낸다(contentWindow.postMessage(…, '*') 를 쓰지 않는다 — 세션이 든 명령이 '*' 로 나가는 일이 없다). 연결 뒤에 오는 창 메시지는 어떤 것이든 받지 않는다:
// iframe 안의 문서가 바뀌어(링크·area·폼 이동 등) 새 문서가 같은 contentWindow·출처 null 로 ready/changed/hotkey 를 보내도 포트가 없으므로 끼어들 수 없다. 포트는 끝내기·다시 열기 때 닫는다.
/** 캔버스로 명령을 보낸다 — 연결된 포트로만 */
function send(type, payload) { if (!st.active || !st.port) return; try { st.port.postMessage(core.makeMessage(st.session, type, payload || {})); } catch (_) {} }
function disconnect() { const p = st.port; st.port = null; if (!p) return; try { p.onmessage = null; } catch (_) {} try { p.close(); } catch (_) {} }
function connect(port) { disconnect(); st.port = port; port.onmessage = onCanvasMessage; try { if (typeof port.start === 'function') port.start(); } catch (_) {} send('init', { mode: 'edit' }); }
window.addEventListener('message', (e) => {
if (!st.active || st.port) return; // 연결 뒤에는 창 메시지를 전혀 받지 않는다(한 번만 맺는 연결)
if (!core.validateMessage(e, { expectedSource: frame().contentWindow, expectedOrigin: 'null', session: st.session, direction: 'fromCanvas' })) return;
if (e.data.type !== 'ready') return;
const port = e.ports && e.ports[0]; if (!port || typeof port.postMessage !== 'function') return; // 포트 없는 ready 는 연결이 아니다
connect(port);
});
/** 포트로 온 캔버스 이벤트(세션·종류·payload 검사) */
function onCanvasMessage(e) {
if (!st.active || !st.port || !core.validatePortMessage(e, { session: st.session, direction: 'fromCanvas' })) return;
const p = e.data.payload;
switch (e.data.type) {
case 'body': if (typeof p.body === 'string') { if (st.savedBody === null) { st.history.reset(p.body); st.savedBody = p.body; st.save.reset(); } render(); } break;
case 'changed': if (typeof p.body === 'string' && st.history.push(p.body)) { markDirtyByBody(p.body); render(); } break;
case 'selected': st.selected = p && p.path ? p : null; renderInspector(); break;
case 'hotkey': if (p.key === 'save') save(); else if (p.key === 'undo') undo(); else if (p.key === 'redo') redo(); break;
case 'error': note('warn', p.message || '편집 오류'); break;
default: break; // ready 는 창 메시지로만 의미가 있다
}
}
function note(level, text) { try { logLine({ level, text }); } catch (_) {} if (level === 'warn') { try { pushMsg('warn', text); } catch (_) {} } }
/** 본문이 저장본과 같으면 "저장됨", 다르면 "저장되지 않음" */
function markDirtyByBody(body) { if (body === st.savedBody) { if (st.save.state() !== 'saving') { st.save.reset(); if (st.savedBody !== null && st.history.size() > 1) st.save.markDirty(), st.save.beginSave(), st.save.saveOk(); } } else st.save.markDirty(); }
// ---------- 켜기·끄기 ----------
function isActive() { return st.active; }
function isDirty() { return st.active && st.save.isDirty(); }
/** 저장하지 않은 변경이 있으면 묻는다. true = 계속 진행해도 됨 */
function discardOk(msg) { if (!isDirty()) return true; return confirm(msg || T('직접 편집에서 저장하지 않은 변경이 있습니다. 버리고 계속할까요?')); }
async function open(file) {
if (!current) return; const rel = file || $('fileSelect').value || 'index.html';
if (!/\.html?$/i.test(rel)) { alert(T('직접 편집은 HTML 페이지에서만 쓸 수 있습니다')); return; }
if (ed.open) { if (ed.dirty && !confirm(T('소스 편집기에 저장하지 않은 변경이 있습니다. 버리고 직접 편집을 열까요?'))) return; ed.dirty = false; setEditorOpen(false); }
st.loading = true; setStateText('불러오는 중…');
try {
const r = await api(`/api/sites/${current.id}/visual/${encodeURIComponent(rel)}`);
disconnect(); // 이전 캔버스 문서의 포트는 닫는다 — 새 문서의 첫 ready 만 받아들인다
st.active = true; st.siteId = current.id; st.file = rel; st.session = r.session; st.baseHash = r.hash; st.rev = r.rev; st.savedBody = null; st.selected = null; st.history = core.createHistory(200); st.save.reset();
if (r.running) note('warn', 'AI 작업이 진행 중입니다. 작업이 끝나기 전에는 직접 편집을 저장할 수 없습니다');
const f = frame(); f.removeAttribute('src'); f.srcdoc = r.canvas; f.classList.remove('hidden');
document.body.classList.add('ve-on'); $('veBar').classList.remove('hidden'); $('vePalette').classList.remove('hidden'); $('veInspector').classList.remove('hidden');
$('btnVisual').setAttribute('aria-pressed', 'true'); $('btnVisual').classList.add('active'); $('btnVisual').textContent = T('직접 편집 중');
if ($('fileSelect').value !== rel) $('fileSelect').value = rel;
try { localStorage.setItem('visualOpen', '1'); } catch (_) {}
renderPalette(); renderInspector(); render();
} catch (e) { st.active = false; disconnect(); alert(T('직접 편집을 열지 못했습니다') + ': ' + e.message); setStateText(''); }
finally { st.loading = false; }
}
/** 끄기. force=true 면 묻지 않는다(사이트가 바뀌어 이미 물었거나 초기화 때). 돌아갈 때 미리보기는 저장된 파일로 다시 읽는다 */
function close(force) {
if (!st.active) return true; if (!force && !discardOk()) return false;
cancelPointerDrag();
st.active = false; disconnect(); st.session = null; st.savedBody = null; st.selected = null; st.save.reset();
document.body.classList.remove('ve-on'); $('veBar').classList.add('hidden'); $('vePalette').classList.add('hidden'); $('veInspector').classList.add('hidden');
$('btnVisual').setAttribute('aria-pressed', 'false'); $('btnVisual').classList.remove('active'); $('btnVisual').textContent = T('직접 편집');
try { localStorage.setItem('visualOpen', '0'); } catch (_) {}
const f = frame(); f.removeAttribute('srcdoc'); if (current && !force) loadFrame(); else if (force && current) loadFrame();
return true;
}
function toggle() { if (st.active) close(false); else open(); }
// ---------- 저장·실행 취소·버리기 ----------
async function save() {
if (!st.active || st.loading) return; if (!st.save.beginSave()) return; render();
const body = st.history.current(); const sent = { siteId: st.siteId, file: st.file, body };
try {
const r = await api(`/api/sites/${sent.siteId}/visual/${encodeURIComponent(sent.file)}`, { method: 'PUT', body: JSON.stringify({ body: sent.body, baseHash: st.baseHash, rev: st.rev }) });
if (!st.active || st.siteId !== sent.siteId || st.file !== sent.file) return;
st.baseHash = r.hash; if (typeof r.rev === 'number') st.rev = r.rev; st.savedBody = sent.body; st.save.saveOk();
if (current && current.id === sent.siteId) { if (r.files) current.files = r.files; if (r.git) current.git = r.git; if (r.versions) current.versions = r.versions; if (typeof r.rev === 'number') current.rev = r.rev; renderGit(current); }
if (st.history.current() !== st.savedBody) st.save.markDirty();
note('info', `직접 편집 저장: ${sent.file}${r.change ? ` (버전 #${r.change.no})` : ''}`);
} catch (e) {
const stale = e.status === 409 && e.body && (e.body.stale || e.body.conflict);
st.save.saveFail(e.message); render();
if (stale) { setTimeout(() => { if (confirm(T('다른 곳에서 먼저 저장한 내용이 있습니다. 최신 내용을 다시 불러올까요? (지금 편집한 내용은 사라집니다. "취소" 를 누르면 화면에 남겨 둡니다)'))) reload(true); }, 50); }
else alert(T('저장 실패') + ': ' + e.message);
}
render();
}
function undo() { const b = st.history.undo(); if (b === null) return; send('applyHtml', { body: b }); markDirtyByBody(b); render(); }
function redo() { const b = st.history.redo(); if (b === null) return; send('applyHtml', { body: b }); markDirtyByBody(b); render(); }
/** 저장된 파일을 다시 불러온다(편집 내용 버림). force 면 묻지 않는다 */
async function reload(force) { if (!st.active) return; if (!force && isDirty() && !confirm(T('저장하지 않은 변경을 버리고 저장된 페이지를 다시 불러올까요?'))) return; const f = st.file; st.active = false; await open(f); }
// ---------- 화면 ----------
function setStateText(t) { const el = $('veState'); if (el) el.textContent = t; }
function render() {
if (!$('veBar')) return;
const s = st.save.state(); const el = $('veState'); el.textContent = st.loading ? T('불러오는 중…') : st.save.label(); el.className = 've-state ' + s;
$('veSave').disabled = !st.active || !(s === 'dirty' || s === 'error') || st.loading; $('veUndo').disabled = !st.active || !st.history.canUndo(); $('veRedo').disabled = !st.active || !st.history.canRedo(); $('veDiscard').disabled = !st.active || !isDirty();
}
// ---------- 팔레트 → 캔버스 포인터 끌기 다리(주 경로) ----------
// HTML5 끌어다 놓기는 부모 문서에서 sandbox(출처 null) iframe 으로 넘어가지 않는다(실제 브라우저 진단: 캔버스에 dragenter/dragover/drop 0건).
// 그래서 팔레트 항목의 포인터(마우스·펜)를 여기서 추적한다: 문턱을 넘으면 끌기 시작, 포인터 캡처로 iframe 위에서도 pointermove 를 받고,
// #frame 의 화면 사각형과 비교해 안쪽일 때만 iframe 상대 좌표를 검증된 메시지(paletteDragAt / paletteDropAt)로 보낸다. 밖·Escape·pointercancel 은 paletteDragCancel.
// 터치 포인터는 다리를 쓰지 않는다(팔레트 스크롤 보존 — 터치는 "추가" 버튼). sandbox 는 그대로(allow-same-origin 없음), 캔버스가 좌표·id 를 다시 검증한다.
let pdrag = null; // { id, li, pointerId, startX, startY, started, inside }
/** 화면 좌표 → iframe 안쪽 상대 좌표(테두리·스크롤바 보정: 모바일 미리보기 틀의 12px 테두리 등) */
function framePoint(clientX, clientY) { const f = frame(); const r = f.getBoundingClientRect(); return core.pointInFrame(clientX, clientY, r, { left: f.clientLeft || 0, top: f.clientTop || 0, width: f.clientWidth || r.width, height: f.clientHeight || r.height }); }
function endPointerDrag() { const d = pdrag; pdrag = null; if (!d) return null; try { d.li.releasePointerCapture(d.pointerId); } catch (_) {} d.li.classList.remove('ve-comp-dragging'); document.body.classList.remove('ve-palette-dragging'); return d; }
/** 취소(밖에서 놓음·Escape·pointercancel·끝내기): 캔버스의 놓기 표시를 지운다 */
function cancelPointerDrag() { const d = endPointerDrag(); if (d && d.started) send('paletteDragCancel', {}); }
function onPalettePointerDown(e, li, id) {
if (!st.active || pdrag) return;
if (e.pointerType === 'touch') return; // 터치: 스크롤·버튼 보존
if (typeof e.button === 'number' && e.button !== 0) return; // 주 버튼만
if (e.target && typeof e.target.closest === 'function' && e.target.closest('button')) return; // "추가" 버튼은 클릭으로
pdrag = { id, li, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, started: false, inside: false };
try { li.setPointerCapture(e.pointerId); } catch (_) {}
}
function onPalettePointerMove(e) {
if (!pdrag || e.pointerId !== pdrag.pointerId) return;
if (!st.active) { endPointerDrag(); return; }
if (!pdrag.started) { if (Math.hypot(e.clientX - pdrag.startX, e.clientY - pdrag.startY) < core.POINTER_DRAG_THRESHOLD) return; pdrag.started = true; pdrag.li.classList.add('ve-comp-dragging'); document.body.classList.add('ve-palette-dragging'); }
if (typeof e.preventDefault === 'function') e.preventDefault();
const p = framePoint(e.clientX, e.clientY);
if (p.inside) { pdrag.inside = true; send('paletteDragAt', { component: pdrag.id, x: p.x, y: p.y }); }
else if (pdrag.inside) { pdrag.inside = false; send('paletteDragCancel', {}); }
}
function onPalettePointerUp(e) {
if (!pdrag || e.pointerId !== pdrag.pointerId) return;
const d = endPointerDrag(); if (!d.started || !st.active) return; // 짧은 클릭은 끌기가 아니다
const p = framePoint(e.clientX, e.clientY);
if (p.inside && core.isComponentId(d.id)) send('paletteDropAt', { component: d.id, x: p.x, y: p.y }); else send('paletteDragCancel', {});
}
/** 팔레트: 구성 요소마다 끌 수 있는 항목 + "추가" 버튼(키보드·마우스 대안) */
function renderPalette() {
const box = $('vePaletteList'); if (!box || box.childElementCount) return;
for (const c of core.COMPONENTS) {
const li = document.createElement('li'); li.className = 've-comp'; li.draggable = true; li.title = `${c.label} — ${c.hint}. 미리보기로 끌어다 놓거나 "추가" 를 누르세요`;
// 주 경로: 포인터 끌기 다리
li.addEventListener('pointerdown', (e) => onPalettePointerDown(e, li, c.id)); li.addEventListener('pointermove', onPalettePointerMove); li.addEventListener('pointerup', onPalettePointerUp);
li.addEventListener('pointercancel', (e) => { if (pdrag && e.pointerId === pdrag.pointerId) cancelPointerDrag(); });
// 대체 경로(포인터 이벤트가 없는 환경): HTML5 DnD — 전용 MIME + 표준 text/plain(이름 공간 값). 포인터 끌기 중에는 네이티브 dragstart 가 pointercancel 로 끊지 못하게 막는다
li.addEventListener('dragstart', (e) => { if (pdrag) { e.preventDefault(); return; } e.dataTransfer.setData(core.COMPONENT_MIME, c.id); e.dataTransfer.setData('text/plain', core.componentDragText(c.id)); e.dataTransfer.effectAllowed = 'copy'; });
const name = document.createElement('span'); name.className = 've-comp-name'; name.textContent = c.label;
const hint = document.createElement('span'); hint.className = 've-comp-hint muted small'; hint.textContent = c.hint;
const add = document.createElement('button'); add.type = 'button'; add.className = 'ghost small-btn'; add.textContent = T('추가'); add.setAttribute('aria-label', `${c.label} 추가 — 선택한 요소 뒤에 (선택이 없으면 페이지 끝에)`);
add.onclick = () => send('insert', { component: c.id, where: 'after' });
li.appendChild(name); li.appendChild(hint); li.appendChild(add); box.appendChild(li);
}
}
/** 속성 창: 선택한 요소의 종류·조작 버튼·편집 가능한 속성 */
function renderInspector() {
const box = $('veInspectorBody'); if (!box) return; const sel = st.selected;
if (!sel) { box.innerHTML = `<p class="muted small">${esc(T('미리보기에서 요소를 클릭하면 여기에 속성이 보입니다. 글은 두 번 클릭(또는 Enter)하면 바로 고칠 수 있습니다.'))}</p><p class="muted small">${esc(T('키보드: Alt+↑/↓ 이동 · Delete 삭제 · Ctrl+D 복제 · Esc 선택 해제 · Ctrl+S 저장'))}</p>`; return; }
const props = sel.props || []; const sty = sel.style || {};
let h = `<div class="ve-sel-head"><b>${esc(sel.label || sel.tag)}</b> <code>${esc(sel.tag)}</code></div>`;
h += `<div class="ve-actions" role="group" aria-label="${esc(T('선택한 요소 조작'))}">`
+ `<button type="button" class="ghost small-btn" data-act="parent" ${sel.parentPath ? '' : 'disabled'} title="${esc(T('바깥(상위) 요소 선택'))}">${esc(T('상위 선택'))}</button>`
+ `<button type="button" class="ghost small-btn" data-act="up" ${sel.canUp ? '' : 'disabled'} aria-label="${esc(T('위로 이동'))}">↑ ${esc(T('위로'))}</button>`
+ `<button type="button" class="ghost small-btn" data-act="down" ${sel.canDown ? '' : 'disabled'} aria-label="${esc(T('아래로 이동'))}">↓ ${esc(T('아래로'))}</button>`
+ `<button type="button" class="ghost small-btn" data-act="dup">${esc(T('복제'))}</button>`
+ `<button type="button" class="ghost small-btn danger" data-act="del">${esc(T('삭제'))}</button></div>`;
const field = (k, label, input) => `<label class="ve-field" data-prop="${k}">${esc(label)}${input}</label>`;
if (props.includes('text')) h += field('text', T('글'), sel.hasChildren ? `<span class="muted small">${esc(T('안에 다른 요소가 있어 미리보기에서 두 번 클릭해 직접 고치세요'))}</span>` : `<textarea rows="3" data-k="text">${esc(sel.text)}</textarea>`);
if (props.includes('href')) h += field('href', T('링크 주소'), `<input data-k="href" value="${esc(sel.href)}" placeholder="https://… 또는 about.html, #section">`);
if (props.includes('src')) h += field('src', T('이미지 주소'), `<input data-k="src" value="${esc(sel.src)}" placeholder="uploads/파일명.jpg 또는 https://…">`);
if (props.includes('alt')) h += field('alt', T('이미지 설명(alt)'), `<input data-k="alt" value="${esc(sel.alt)}">`);
if (props.includes('align')) h += field('align', T('정렬'), `<select data-k="align"><option value="">${esc(T('기본'))}</option><option value="left"${sty.align === 'left' ? ' selected' : ''}>${esc(T('왼쪽'))}</option><option value="center"${sty.align === 'center' ? ' selected' : ''}>${esc(T('가운데'))}</option><option value="right"${sty.align === 'right' ? ' selected' : ''}>${esc(T('오른쪽'))}</option></select>`);
if (props.includes('color')) h += field('color', T('글자색'), `<input data-k="color" value="${esc(sty.color)}" placeholder="#1f2933 또는 navy">`);
if (props.includes('background')) h += field('background', T('배경색'), `<input data-k="background" value="${esc(sty.background)}" placeholder="#ffffff 또는 transparent">`);
if (props.includes('padding')) h += field('padding', T('안쪽 여백(padding)'), `<input data-k="padding" value="${esc(sty.padding)}" placeholder="예: 20px 또는 10px 20px">`);
if (props.includes('margin')) h += field('margin', T('바깥 여백(margin)'), `<input data-k="margin" value="${esc(sty.margin)}" placeholder="예: 0 0 16px">`);
h += `<div class="row"><button type="button" class="primary small-btn" data-act="apply">${esc(T('속성 적용'))}</button><span class="muted small" id="veInspectorMsg"></span></div>`;
box.innerHTML = h;
box.querySelectorAll('[data-act]').forEach((b) => { b.onclick = () => {
const a = b.dataset.act;
if (a === 'parent') send('select', { path: sel.parentPath }); else if (a === 'up') send('move', { dir: 'up' }); else if (a === 'down') send('move', { dir: 'down' }); else if (a === 'dup') send('duplicate', {}); else if (a === 'del') { if (confirm(T('선택한 요소를 삭제할까요?'))) send('delete', {}); }
else if (a === 'apply') applyInspector(box);
}; });
box.querySelectorAll('input,select,textarea').forEach((i) => { i.addEventListener('keydown', (e) => { if (e.key === 'Enter' && i.tagName !== 'TEXTAREA') { e.preventDefault(); applyInspector(box); } }); if (i.tagName === 'SELECT') i.addEventListener('change', () => applyInspector(box)); });
}
/** 속성 창 값 검사(주소 안전 규칙) 후 캔버스에 적용 */
function applyInspector(box) {
const props = {}; const msg = $('veInspectorMsg'); if (msg) msg.textContent = '';
for (const i of box.querySelectorAll('[data-k]')) {
const k = i.dataset.k; const v = i.value;
if (k === 'href' || k === 'src') { const r = core.safeUrlInput(v, k === 'src' ? 'image' : 'link'); if (!r.ok) { if (msg) msg.textContent = r.error; i.focus(); return; } props[k] = r.value; }
else if (['color', 'background', 'padding', 'margin'].includes(k)) { if (/[;{}<>]|url\s*\(|expression|javascript/i.test(v)) { if (msg) msg.textContent = T('허용되지 않는 스타일 값입니다'); i.focus(); return; } props[k] = v.trim(); }
else props[k] = v;
}
send('setProps', { props });
}
/** 팔레트·속성 창 접기/펼치기 — 좁은 화면에서 캔버스를 가리지 않게 */
function bindToggles() {
for (const [btn, side] of [['vePaletteToggle', 'vePalette'], ['veInspectorToggle', 'veInspector']]) {
const b = $(btn); if (!b) continue;
b.onclick = () => { const s = $(side); const open = s.classList.toggle('collapsed'); b.setAttribute('aria-expanded', open ? 'false' : 'true'); b.textContent = open ? '▸' : '▾'; };
}
if (window.matchMedia && window.matchMedia('(max-width: 900px)').matches) for (const id of ['vePaletteToggle', 'veInspectorToggle']) { const b = $(id); if (b && b.getAttribute('aria-expanded') !== 'false') b.click(); }
}
// ---------- app.js 와의 연결점 ----------
/**
* 사이트 응답이 바뀌었을 때(AI 수정 완료·버전 복원·저장·사이트 전환·페이지 삭제). 저장하지 않은 변경은 어떤 경로로도 묻지 않고 버리지 않는다(1.8.0 검토 수정):
* - 다른 사이트(또는 사이트 없음): close(false) — 변경이 있으면 묻고, 취소하면 편집기·사이트를 그대로 둔다(app.js openSite 는 전환 전에 먼저 묻는다; 여기는 마지막 방어선).
* - 편집 중인 페이지가 사라짐: 변경이 없으면 끝내고, 있으면 묻는다 — 취소하면 화면에 남겨 두어 다른 이름으로 저장하거나 복사할 수 있게 한다.
* - 같은 페이지가 다른 작업으로 바뀜: 변경이 없으면 최신 내용으로 다시 열고, 있으면 알린다(저장 때 baseHash 충돌로 보호된다).
*/
function onSiteUpdated(site) {
if (!st.active) return; if (!site || site.id !== st.siteId) { close(false); return; }
const files = (site.files || []);
if (!files.includes(st.file)) {
if (!discardOk(T(`직접 편집 중이던 ${st.file} 이 없어졌습니다. 저장하지 않은 변경을 버리고 편집을 끝낼까요? ("취소" 를 누르면 화면에 남겨 둡니다)`))) { note('warn', `${st.file} 이 없어졌지만 저장하지 않은 편집 내용을 화면에 남겨 두었습니다. 저장은 실패할 수 있으니 내용을 복사해 두세요`); return; }
note('warn', `직접 편집 중이던 ${st.file} 이 없어져 편집을 끝냅니다`); close(true); return;
}
if (isDirty()) note('warn', `${st.file} 이 다른 작업으로 바뀌었습니다. 지금 화면의 편집 내용은 저장되지 않은 이전 내용이며, 저장하면 최신 내용과의 충돌을 알려 줍니다`); else reload(true);
}
/** 페이지 선택 상자가 바뀜. 저장하지 않은 변경이 있으면 묻고, 취소하면 선택을 되돌린다 */
function onPageChange(rel) { if (!st.active) return false; if (rel === st.file) return true; if (!discardOk(T('저장하지 않은 변경이 있습니다. 버리고 다른 페이지를 열까요?'))) { $('fileSelect').value = st.file; return true; } st.active = false; open(rel); return true; }
function bind() {
$('btnVisual').onclick = toggle; $('veSave').onclick = save; $('veUndo').onclick = undo; $('veRedo').onclick = redo; $('veClose').onclick = () => close(false); $('veDiscard').onclick = () => reload(false);
document.addEventListener('keydown', (e) => { if (!st.active) return; if (e.key === 'Escape' && pdrag) { e.preventDefault(); cancelPointerDrag(); return; } const mod = e.ctrlKey || e.metaKey; const k = e.key.toLowerCase(); if (mod && k === 's') { e.preventDefault(); save(); } else if (mod && k === 'z' && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement && document.activeElement.tagName)) { e.preventDefault(); if (e.shiftKey) redo(); else undo(); } });
window.addEventListener('beforeunload', (e) => { if (isDirty()) { e.preventDefault(); e.returnValue = ''; } });
bindToggles(); renderInspector(); render();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind); else bind();
return { open, close, toggle, save, undo, redo, reload, isActive, isDirty, discardOk, onSiteUpdated, onPageChange, state: () => ({ active: st.active, siteId: st.siteId, file: st.file, save: st.save.state() }) };
})();