[verified] feat: add Webmaker visual editor v0.2.0

This commit is contained in:
oz
2026-10-28 15:07:35 +00:00
parent 4b3714e37c
commit 0a56d4612c
40 changed files with 4770 additions and 191 deletions
+35 -14
View File
@@ -119,7 +119,10 @@ let switching = false;
* @param {string|null} targetId 열려는 사이트 (null = 새 사이트 만들기 화면)
*/
function discardOk(targetId) {
if (!ed.dirty || (current && targetId === current.id)) return true;
if (current && targetId === current.id) return true;
// v1.8.0: 직접 편집(시각 편집기)의 저장하지 않은 변경도 같은 자리에서 묻는다
if (window.VisualEditor && VisualEditor.isDirty() && !VisualEditor.discardOk(T('직접 편집에서 저장하지 않은 변경이 있습니다. 버리고 다른 사이트로 바꿀까요?'))) return false;
if (!ed.dirty) return true;
if (!confirm(T('저장하지 않은 소스 변경이 있습니다. 버리고 다른 사이트로 바꿀까요?'))) return false;
ed.dirty = false; return true;
}
@@ -141,6 +144,8 @@ function applyTabView(name) {
async function showTab(name) {
if (!TABS.includes(name)) name = 'create';
{ const target = name === 'create' ? createId : SITE_TABS.includes(name) ? manageId : (current && current.id); if (!['settings', 'domains', 'dns', 'mail', 'credits', 'home'].includes(name) && !discardOk(target || null)) return; }
// v1.8.0 검토 수정: 홈 화면도 고른 사이트(manageId)가 지금 사이트와 다르면 그 사이트를 여므로, 직접 편집의 저장하지 않은 변경을 먼저 묻는다(취소하면 화면을 바꾸지 않는다)
if (name === 'home' && manageId && current && current.id !== manageId && !discardOk(manageId)) return;
applyTabView(name);
try { localStorage.setItem('activeTab', name); } catch (_) {}
if (switching) return; switching = true;
@@ -756,7 +761,7 @@ async function renderInquiries() {
$('btnInquiryReload').onclick = renderInquiries;
// ---------- 버전(되돌리기) ----------
const VER_KIND = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원', deploy: '배포본', import: '가져옴' };
const VER_KIND = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '소스 편집', 'visual-edit': '직접 편집', restore: '복원', deploy: '배포본', import: '가져옴' }; // v1.8.0 visual-edit: 시각 편집기 저장
const CMD_STATUS = { running: '실행 중', done: '완료', error: '오류', cancelled: '중단', interrupted: '끊김' };
function renderGit(site) {
const list = site.versions || []; $('versionsCard').classList.remove('hidden');
@@ -791,9 +796,14 @@ function renderPreview(site, reload) {
sel.classList.toggle('hidden', htmls.length < 2);
const hasIndex = htmls.length > 0;
renderEmptyReason(site, hasIndex);
$('emptyState').classList.toggle('hidden', hasIndex);
$('frame').classList.toggle('hidden', !hasIndex);
if (hasIndex && reload) loadFrame();
// v1.8.0: 직접 편집 중이면 미리보기를 덮어쓰지 않고 편집기에 알린다(저장하지 않은 변경 보호). 아니면 예전처럼 다시 읽는다.
// v1.8.0 검토 수정: HTML 페이지가 없어진 갱신도 편집기가 스스로 판단한다(onSiteUpdated — 변경이 있으면 묻고, 취소하면 캔버스를 그대로 둔다). 강제로 끝내지 않는다.
const veActive = !!(window.VisualEditor && VisualEditor.isActive());
if (veActive && (reload || !hasIndex)) VisualEditor.onSiteUpdated(site); else if (hasIndex && reload) loadFrame();
const veStill = !!(window.VisualEditor && VisualEditor.isActive());
$('emptyState').classList.toggle('hidden', hasIndex || veStill);
$('frame').classList.toggle('hidden', !hasIndex && !veStill);
$('btnVisual').disabled = !hasIndex && !veStill; // v1.8.0 직접 편집은 HTML 페이지가 있을 때만(편집 중이면 끝내기 버튼으로 남는다)
}
/**
* 미리보기할 HTML 이 없을 때의 안내 (v1.3.3). 1.3.2 까지는 가져오기·복사가 실패했거나 진행 중이어도 "새 사이트를 만들면…"
@@ -861,7 +871,7 @@ async function loadEditFile(rel) {
if (ed.dirty && !confirm('저장하지 않은 변경이 있습니다. 버리고 다른 파일을 열까요?')) { $('editFile').value = ed.file; return; }
try {
const r = await api(`/api/sites/${current.id}/files/${encodeURIComponent(rel)}`);
ed.file = rel; ed.saved = r.content; ed.dirty = false;
ed.file = rel; ed.saved = r.content; ed.dirty = false; ed.hash = r.hash || null; // v1.8.0 검토 수정: 저장 기준 hash — 다른 곳이 먼저 저장했으면 서버가 409 로 알린다
$('editor').value = r.content; $('editFile').value = rel; updateLineNos(); setDirty(false);
} catch (e) { alert('파일 열기 실패: ' + e.message); }
}
@@ -893,15 +903,20 @@ async function saveEditFile() {
if (!current || !ed.file || !ed.dirty) return;
$('btnSave').disabled = true;
// v1.3.17 (B22): 보낸 내용·사이트·파일을 붙잡아 두고, 응답 뒤에는 그 내용만 "저장됨" 으로 본다 (저장 중에 더 친 글자는 저장 안 됨으로 남김)
const sent = { siteId: current.id, file: ed.file, content: $('editor').value };
const sent = { siteId: current.id, file: ed.file, content: $('editor').value, baseHash: ed.hash || undefined };
try {
const r = await api(`/api/sites/${sent.siteId}/files/${encodeURIComponent(sent.file)}`, { method: 'PUT', body: JSON.stringify({ content: sent.content }) });
// v1.8.0 검토 수정: baseHash(파일을 열 때 받은 hash)를 함께 보낸다 — 직접 편집·AI 수정·다른 창이 먼저 저장했으면 서버가 409(stale) 로 거절해 조용히 덮어쓰지 않는다
const r = await api(`/api/sites/${sent.siteId}/files/${encodeURIComponent(sent.file)}`, { method: 'PUT', body: JSON.stringify({ content: sent.content, baseHash: sent.baseHash }) });
if (!current || current.id !== sent.siteId || ed.file !== sent.file) return; // 그 사이 다른 사이트·파일로 바뀜
ed.saved = sent.content; setDirty($('editor').value !== sent.content); if (ed.dirty) $('btnSave').disabled = false;
ed.saved = sent.content; if (r.hash) ed.hash = r.hash; setDirty($('editor').value !== sent.content); if (ed.dirty) $('btnSave').disabled = false;
if (r.files) { current.files = r.files; fillEditFiles(); }
if (r.git) { current.git = r.git; renderGit(current); }
$('frame').removeAttribute('srcdoc'); loadFrame();
} catch (e) { alert('저장 실패: ' + e.message); $('btnSave').disabled = false; }
} catch (e) {
$('btnSave').disabled = false;
if (e.status === 409 && e.body && e.body.stale) { if (confirm(T('다른 곳(직접 편집·AI 수정·다른 창)에서 이 파일을 먼저 저장했습니다. 최신 내용을 다시 불러올까요? (지금 편집기의 내용은 사라집니다. "취소" 를 누르면 편집기에 남겨 둡니다)'))) { ed.dirty = false; loadEditFile(sent.file); } return; }
alert('저장 실패: ' + e.message);
}
}
$('editor').addEventListener('input', () => {
setDirty($('editor').value !== ed.saved); updateLineNos();
@@ -915,7 +930,7 @@ $('editor').addEventListener('keydown', (e) => {
$('btnSave').onclick = saveEditFile;
$('btnRevert').onclick = () => { if (!ed.dirty || confirm('변경 내용을 버리고 저장된 내용으로 되돌릴까요?')) { $('editor').value = ed.saved; setDirty(false); updateLineNos(); $('frame').removeAttribute('srcdoc'); loadFrame(); } };
$('editFile').onchange = (e) => loadEditFile(e.target.value);
$('btnToggleSource').onclick = () => setEditorOpen(!ed.open);
$('btnToggleSource').onclick = () => { if (!ed.open && window.VisualEditor && VisualEditor.isActive() && !VisualEditor.close(false)) return; setEditorOpen(!ed.open); }; // v1.8.0 소스 편집기와 직접 편집은 한 번에 하나만 (미리보기 iframe 을 함께 쓴다)
$('liveApply').onchange = () => { if ($('liveApply').checked) applyLivePreview(); else { $('frame').removeAttribute('srcdoc'); loadFrame(); } };
// 분할 크기 드래그
(() => {
@@ -925,7 +940,7 @@ $('liveApply').onchange = () => { if ($('liveApply').checked) applyLivePreview()
window.addEventListener('mousemove', (e) => { if (!dragging) return; const box = $('frame').parentElement.getBoundingClientRect(); const h = Math.min(Math.max(box.bottom - e.clientY, 120), box.height - 120); pane.style.height = h + 'px'; });
window.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; document.body.style.cursor = ''; $('frame').style.pointerEvents = ''; });
})();
window.addEventListener('beforeunload', (e) => { if (ed.dirty) { e.preventDefault(); e.returnValue = ''; } });
window.addEventListener('beforeunload', (e) => { if (ed.dirty || (window.VisualEditor && VisualEditor.isDirty())) { e.preventDefault(); e.returnValue = ''; } }); // v1.8.0 직접 편집 변경도 보호
// ---------- 사이트 로드 / 이벤트 ----------
function connectEvents(id) {
@@ -960,6 +975,9 @@ function connectEvents(id) {
es.onerror = () => { /* 브라우저가 자동 재접속 */ };
}
async function openSite(id, opts = {}) {
// v1.8.0 검토 수정: 다른 사이트로 바꾸기 **전에** 직접 편집의 저장하지 않은 변경을 묻는다(여기가 모든 호출 경로의 마지막 방어선 — 홈·탭·선택 상자·다른 모듈의 openSite 포함).
// 취소하면 아무것도 바꾸지 않고(현재 사이트·편집기 유지) 지금 사이트를 돌려준다. 승낙하면 편집기를 끝낸 뒤 진행한다.
if (window.VisualEditor && VisualEditor.isActive() && VisualEditor.state().siteId !== id) { if (!VisualEditor.close(false)) { syncPicker(); return current; } }
const site = await api(`/api/sites/${id}`);
current = site;
const prog_ = inProgress(site);
@@ -995,6 +1013,8 @@ async function openSite(id, opts = {}) {
ed.file = null; ed.dirty = false; $('editor').value = ''; setDirty(false); updateLineNos();
let wantOpen = false; try { wantOpen = localStorage.getItem('sourceOpen') === '1'; } catch (_) {}
setEditorOpen(wantOpen);
// v1.8.0: 사이트가 바뀌면 직접 편집은 끝낸다(저장하지 않은 변경은 위에서 close(false) 로 이미 물었다 — 여기 남아 있으면 변경이 없는 경우뿐). 같은 사이트를 다시 열 때는 그대로 둔다
if (window.VisualEditor && VisualEditor.isActive() && VisualEditor.state().siteId !== site.id) VisualEditor.close(false);
}
/** 위쪽 사이트 선택 아래: 사이트 상태 배지와 운영 주소 (모든 탭 공통, 높이 고정) */
function renderSiteMeta(site) {
@@ -1043,6 +1063,7 @@ function resetToNew(clearManage = true) {
renderUsage({ totals: {}, commands: [] }); $('attachCard').classList.add('hidden'); $('versionsCard').classList.add('hidden'); $('inquiryCard').classList.add('hidden'); $('siteInfoCard').classList.add('hidden');
$('siteUrl').classList.remove('live'); $('btnOpen').textContent = '새 탭에서 열기';
ed.file = null; ed.dirty = false; ed.open = false; $('editorPane').classList.add('hidden'); $('splitter').classList.add('hidden'); $('btnToggleSource').disabled = true; $('btnToggleSource').classList.remove('active'); $('btnToggleSource').textContent = '소스 보기';
if (window.VisualEditor && VisualEditor.isActive()) VisualEditor.close(true); $('btnVisual').disabled = true; // v1.8.0
$('siteUrl').textContent = '아직 생성된 페이지가 없습니다'; $('btnOpen').classList.add('disabled'); $('btnReload').disabled = true; $('btnCopy').disabled = true;
$('fileSelect').classList.add('hidden'); renderEmptyReason(null, false); $('emptyState').classList.remove('hidden'); $('frame').classList.add('hidden'); $('frame').src = 'about:blank';
$('siteSelect').value = '';
@@ -1102,8 +1123,8 @@ $('btnCancel').onclick = async () => {
$('btnCancel').disabled = false;
};
$('fAttachNow').onchange = (e) => { uploadNow([...e.target.files]); e.target.value = ''; };
$('btnReload').onclick = loadFrame;
$('fileSelect').onchange = loadFrame;
$('btnReload').onclick = () => { if (window.VisualEditor && VisualEditor.isActive()) { VisualEditor.reload(false); return; } loadFrame(); }; // v1.8.0 직접 편집 중 새로고침 = 저장된 페이지 다시 불러오기(변경이 있으면 묻는다)
$('fileSelect').onchange = () => { if (window.VisualEditor && VisualEditor.isActive() && VisualEditor.onPageChange($('fileSelect').value)) return; loadFrame(); }; // v1.8.0 페이지 전환 때 저장하지 않은 직접 편집 보호
/**
* v1.4.17 (요구 1) 미리보기 안에서 페이지를 옮겨 다닐 때 위 도구 줄의 페이지 선택(#fileSelect)과 소스 편집기를 따라가게 한다.
* 미리보기 iframe 은 sandbox(allow-same-origin 없음)라 빌더가 iframe 의 주소를 읽을 수 없다. 대신 모든 생성 페이지에 들어 있는