[verified] feat: add Webmaker visual editor v0.2.0
This commit is contained in:
@@ -9,10 +9,10 @@ const KIND = { create: '생성', update: '수정', 'plan-analyze': '이해 확
|
||||
const MODE = { ai: 'AI에게 맡기기', manual: '내가 고르기', quick: '기본', import: '사이트 관리(가져옴)', copy: '사이트 복사' };
|
||||
const TIER = { basic: '기본', standard: '중급', premium: '고급' };
|
||||
const STATUS = { running: '실행 중', deploying: '배포 중', 'waiting-credit': '캐시 대기', done: '완료', error: '오류', cancelled: '중단', interrupted: '끊김', idle: '대기' };
|
||||
const VER = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원' };
|
||||
const VER = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '소스 편집', 'visual-edit': '직접 편집', restore: '복원' }; // v1.8.0 visual-edit
|
||||
let overview = null, es = null, me = null, curSite = null, billingData = null, curBillUser = null, settingsData = null, siteFilter = 'all';
|
||||
const DS = { pending: '배포 대기중', deployed: '발행 완료', working: '작업 중', deploying: '배포 중', empty: '완성 전', transferred: '전송 완료', 'vhost-wait': '웹서버 설정 대기', 'dns-wait': 'DNS 설정 대기', 'cert-wait': '인증서 준비 중(HTTP)', 'check-failed': '접속 확인 실패', 'vhost-failed': '도메인 연결 실패', failed: '배포 실패' }; // v1.6.31 (요구 14) 단계 구분
|
||||
const CHG = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원', deploy: '배포본' };
|
||||
const CHG = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '소스 편집', 'visual-edit': '직접 편집', restore: '복원', deploy: '배포본' };
|
||||
|
||||
/**
|
||||
* 관리자 API 호출.
|
||||
|
||||
@@ -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 의 주소를 읽을 수 없다. 대신 모든 생성 페이지에 들어 있는
|
||||
|
||||
@@ -429,8 +429,21 @@
|
||||
<a id="btnOpen" class="btn ghost disabled" target="_blank" rel="noopener">새 탭에서 열기</a>
|
||||
<button id="btnCopy" class="ghost" disabled>URL 복사</button>
|
||||
<button id="btnToggleSource" class="ghost" disabled title="우측 화면을 위(미리보기)/아래(소스) 로 분할">소스 보기</button>
|
||||
<!-- v1.8.0 직접 편집(시각 편집기): 미리보기 화면에서 글을 바로 고치고 블록을 끌어 옮기고 구성 요소를 놓는다. 사이트와 HTML 페이지가 골라져 있을 때만 켜진다 (public/visual-editor-ui.js) -->
|
||||
<button id="btnVisual" class="ghost" disabled aria-pressed="false" title="미리보기 화면에서 글을 바로 고치고 블록을 끌어 옮기는 직접 편집 모드 (AI 캐시를 쓰지 않습니다)">직접 편집</button>
|
||||
</div>
|
||||
<div class="preview" id="previewArea">
|
||||
<!-- v1.8.0 직접 편집 도구 줄: 저장 상태(aria-live) · 실행 취소 · 다시 실행 · 버리고 다시 불러오기 · 저장(Ctrl+S) · 편집 끝내기 -->
|
||||
<div id="veBar" class="ve-bar hidden" role="toolbar" aria-label="직접 편집 도구">
|
||||
<span class="ve-title">직접 편집</span>
|
||||
<span id="veState" class="ve-state clean" aria-live="polite">변경 없음</span>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" id="veUndo" class="ghost small-btn" disabled title="실행 취소 (Ctrl+Z)" aria-keyshortcuts="Control+Z">실행 취소</button>
|
||||
<button type="button" id="veRedo" class="ghost small-btn" disabled title="다시 실행 (Ctrl+Shift+Z)" aria-keyshortcuts="Control+Shift+Z">다시 실행</button>
|
||||
<button type="button" id="veDiscard" class="ghost small-btn" disabled title="저장하지 않은 변경을 버리고 저장된 페이지를 다시 불러옵니다">버리고 다시 불러오기</button>
|
||||
<button type="button" id="veSave" class="primary small-btn" disabled title="선택한 HTML 파일에 저장하고 버전으로 남깁니다 (Ctrl+S)" aria-keyshortcuts="Control+S">저장 (Ctrl+S)</button>
|
||||
<button type="button" id="veClose" class="ghost small-btn" title="직접 편집을 끝내고 미리보기로 돌아갑니다">편집 끝내기</button>
|
||||
</div>
|
||||
<div id="emptyState" class="empty">
|
||||
<div class="empty-inner">
|
||||
<h3>미리보기</h3>
|
||||
@@ -439,7 +452,18 @@
|
||||
</div>
|
||||
<!-- v1.4.15 사용자 첫 화면 페이지: 사이트를 고르기 전 오른쪽 창에만 보이는 관리자 지정 페이지 (app.js showWelcome) -->
|
||||
<div id="welcomeBox" class="welcome-box hidden"><iframe id="welcomeFrame" class="welcome-frame" title="첫 화면" sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"></iframe></div>
|
||||
<div class="frame-wrap" id="frameWrap"><iframe id="frame" class="hidden" title="생성된 페이지 미리보기" sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads"></iframe><!-- v1.3.17 (S02): 미리보기는 별도 출처(sandbox, allow-same-origin 없음) — 사이트 스크립트가 빌더 세션·저장소를 쓰지 못한다 --></div>
|
||||
<!-- v1.8.0 직접 편집 무대: 왼쪽 구성 요소 팔레트 · 가운데 미리보기(캔버스) · 오른쪽 속성 창. 팔레트·속성 창은 캔버스를 가리지 않고 옆(좁은 화면에서는 아래)에 놓이며 접을 수 있다 -->
|
||||
<div class="ve-stage" id="veStage">
|
||||
<aside id="vePalette" class="ve-side ve-palette hidden" role="region" aria-label="구성 요소 팔레트">
|
||||
<div class="ve-side-head"><button type="button" id="vePaletteToggle" class="ve-toggle" aria-expanded="true" aria-controls="vePaletteList" title="팔레트 접기/펼치기">▾</button><b>구성 요소</b> <button type="button" class="help" data-tip="항목을 미리보기로 끌어다 놓으면 그 자리에 들어갑니다. 마우스를 쓰기 어려우면 '추가' 버튼으로 선택한 요소 뒤(선택이 없으면 페이지 끝)에 넣을 수 있습니다.">?</button></div>
|
||||
<ul id="vePaletteList" class="ve-palette-list" aria-label="구성 요소 목록"></ul>
|
||||
</aside>
|
||||
<div class="frame-wrap" id="frameWrap"><iframe id="frame" class="hidden" title="생성된 페이지 미리보기" sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads"></iframe><!-- v1.3.17 (S02): 미리보기는 별도 출처(sandbox, allow-same-origin 없음) — 사이트 스크립트가 빌더 세션·저장소를 쓰지 못한다. v1.8.0 직접 편집 캔버스도 같은 iframe(srcdoc)을 쓴다 --></div>
|
||||
<aside id="veInspector" class="ve-side ve-inspector hidden" role="region" aria-label="선택한 요소 속성">
|
||||
<div class="ve-side-head"><button type="button" id="veInspectorToggle" class="ve-toggle" aria-expanded="true" aria-controls="veInspectorBody" title="속성 창 접기/펼치기">▾</button><b>선택한 요소</b></div>
|
||||
<div id="veInspectorBody" class="ve-inspector-body"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<div id="splitter" class="splitter hidden" title="드래그하여 크기 조절"></div>
|
||||
<div id="editorPane" class="editor hidden">
|
||||
<div class="editor-bar">
|
||||
@@ -502,5 +526,7 @@
|
||||
<script src="/flow-ui.js"></script><!-- v1.6.40 홈(작업 흐름 지도)·레일 상태 배지·배포 점검표 -->
|
||||
<script src="/credits-ui.js"></script><!-- v1.6.40 캐시·충전 화면(선불 충전 상품·주문) -->
|
||||
<script src="/site-info.js"></script><!-- v1.7.2 오른쪽 캔버스 사이트 정보 페이지(그래프) -->
|
||||
<script src="/visual-editor-core.js"></script><!-- v1.8.0 직접 편집 공용 핵심(실행 취소·저장 상태·메시지 규약·구성 요소) -->
|
||||
<script src="/visual-editor-ui.js"></script><!-- v1.8.0 직접 편집 빌더 쪽 제어(팔레트·속성 창·저장) -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -752,6 +752,39 @@ details.dom-rec-details > summary { cursor: pointer; font-size: 13px; font-weigh
|
||||
.si-disk-total { font-size: 22px; font-weight: 700; letter-spacing: -.01em; }
|
||||
.si-table th, .si-table td { white-space: nowrap; }
|
||||
|
||||
/* ===== v1.8.0: 직접 편집(시각 편집기) — 도구 줄(.ve-bar) · 무대(.ve-stage: 팔레트 | 캔버스 | 속성 창) ===== */
|
||||
.ve-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line); background: #f8fafc; flex-wrap: wrap; }
|
||||
.ve-bar .ve-title { font-weight: 700; font-size: 13px; color: #1e3a8a; }
|
||||
.ve-bar .spacer { flex: 1; }
|
||||
.ve-state { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: #e5e7eb; color: #374151; }
|
||||
.ve-state.dirty, .ve-state.error { background: #fee2e2; color: #991b1b; font-weight: 600; } .ve-state.saving { background: #fef3c7; color: var(--run); } .ve-state.saved { background: #d1fae5; color: var(--ok); }
|
||||
.ve-stage { flex: 1; min-height: 0; display: flex; }
|
||||
.ve-stage .frame-wrap { flex: 1; min-width: 0; }
|
||||
.ve-side { flex: 0 0 240px; max-width: 40%; border-left: 1px solid var(--line); background: #fff; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.ve-side.ve-palette { border-left: 0; border-right: 1px solid var(--line); }
|
||||
.ve-side.collapsed { flex-basis: 34px; } .ve-side.collapsed .ve-side-head b, .ve-side.collapsed .ve-side-head .help { display: none; } .ve-side.collapsed > *:not(.ve-side-head) { display: none; }
|
||||
.ve-side-head { display: flex; align-items: center; gap: 6px; padding: 8px; border-bottom: 1px solid var(--line); font-size: 13px; }
|
||||
.ve-toggle { all: unset; cursor: pointer; width: 22px; height: 22px; border-radius: 6px; text-align: center; line-height: 22px; color: var(--muted); } .ve-toggle:hover, .ve-toggle:focus-visible { background: #eef2ff; outline: 2px solid #c7d7ff; }
|
||||
.ve-palette-list { list-style: none; margin: 0; padding: 8px; overflow: auto; display: flex; flex-direction: column; gap: 6px; }
|
||||
.ve-comp { border: 1px dashed #c9d1dc; border-radius: 8px; padding: 8px; background: #f8fafc; cursor: grab; display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; align-items: center; }
|
||||
.ve-comp:hover { border-color: var(--accent); background: #eef2ff; } .ve-comp .ve-comp-name { font-weight: 600; font-size: 13px; } .ve-comp .ve-comp-hint { grid-column: 1; } .ve-comp button { grid-column: 2; grid-row: 1 / span 2; }
|
||||
/* 포인터 끌기 다리(visual-editor-ui.js): 끌기 중 글 선택 방지·잡는 손 모양. 터치는 다리를 쓰지 않으므로 세로 스크롤(pan-y)은 그대로 */
|
||||
.ve-comp { user-select: none; touch-action: pan-y; } .ve-comp.ve-comp-dragging { opacity: .6; cursor: grabbing; border-style: solid; border-color: var(--accent); } body.ve-palette-dragging, body.ve-palette-dragging * { cursor: grabbing !important; user-select: none; }
|
||||
.ve-inspector-body { padding: 10px; overflow: auto; font-size: 13px; }
|
||||
.ve-sel-head { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||
.ve-actions { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; } .ve-actions .danger { color: #991b1b; }
|
||||
.ve-field { display: block; margin-bottom: 8px; } .ve-field textarea { resize: vertical; }
|
||||
body.ve-on .toolbar #btnVisual.active { background: #dbeafe; border-color: #93c5fd; color: #1e3a8a; }
|
||||
/* 직접 편집 중(body.ve-on, visual-editor-ui.js 가 켜고 끈다): 옛 작업 칸(.left)을 숨기고 .app 을 "레일 + 전체 폭 캔버스" 두 칸으로 다시 짠다.
|
||||
실제 브라우저 검증(1280×633)에서 세 칸 그대로면 iframe 이 115px 로 줄어 글자가 한 자씩 떨어졌다 — 이제 1280px 에서 캔버스 = 1280 − 레일 232 − 패널 240×2 = 568px.
|
||||
편집을 끝내면 class 가 빠져 원래 세 칸 배치가 그대로 돌아온다(작업 칸 내용은 숨겨질 뿐 바뀌지 않는다). */
|
||||
body.ve-on .app { grid-template-columns: var(--rail-w) minmax(0, 1fr); }
|
||||
body.ve-on .left { display: none; }
|
||||
@media (max-width: 1180px) { body.ve-on .ve-side { flex-basis: 190px; } } /* 레일 204px 구간: 1100px 에서도 캔버스 ≥ 500px */
|
||||
@media (max-width: 900px) { .ve-stage { flex-direction: column; } .ve-side { flex: 0 0 auto; max-width: none; max-height: 32vh; border-left: 0; border-top: 1px solid var(--line); } .ve-side.ve-palette { order: 2; border-right: 0; } .ve-inspector { order: 3; } .ve-stage .frame-wrap { order: 1; min-height: 40vh; } .ve-side.collapsed { max-height: 36px; }
|
||||
/* 390px 등 좁은 화면: 레일(가로 띠) 바로 아래에 캔버스. 옛 작업 칸 행이 없으므로 캔버스가 y≈2200 으로 밀리지 않는다 */
|
||||
body.ve-on .app { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); height: 100vh; } body.ve-on .right { min-height: 0; } }
|
||||
|
||||
/* ===== v1.7.3: 메일 사용량 (메일 화면 한눈에 보기 · 사이트 정보) ===== */
|
||||
.mg-usage .git-list { margin: 6px 0 4px; } .mg-usage .git-list .st { white-space: normal; text-align: right; }
|
||||
.mg-usage > div + div { margin-top: 3px; }
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* public/visual-editor-core.js — v1.8.0 직접 편집(시각 편집기)의 공용 핵심. 브라우저(빌더 화면 · 캔버스 iframe)와 Node(테스트) 양쪽에서 같은 코드를 쓴다.
|
||||
* DOM 에 의존하지 않는 순수 로직만 둔다: 실행 취소/다시 실행 기록, 저장 상태 기계, postMessage 규약 검증, 구성 요소(팔레트) 목록,
|
||||
* 재배치 계산(순서 이동·놓을 위치), 속성 편집 규칙(요소별 편집 가능 속성·주소 입력 검사·한국어 이름).
|
||||
* 브라우저: window.WMVisualCore / Node: module.exports
|
||||
*/
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||
else root.WMVisualCore = factory();
|
||||
}(typeof window !== 'undefined' ? window : this, function () {
|
||||
'use strict';
|
||||
const VERSION = '1.8.0';
|
||||
/** 메시지 종류: 부모(빌더) → 캔버스 / 캔버스 → 부모. 이 목록에 없는 종류는 어느 쪽도 처리하지 않는다 */
|
||||
const MESSAGE_TYPES = {
|
||||
toCanvas: ['init', 'select', 'move', 'duplicate', 'delete', 'insert', 'setProps', 'applyHtml', 'setMode', 'requestBody', 'paletteDragAt', 'paletteDragCancel', 'paletteDropAt'],
|
||||
fromCanvas: ['ready', 'selected', 'changed', 'error', 'body', 'hotkey'],
|
||||
};
|
||||
/** 보내는 메시지 봉투. wmve 판별값·세션·종류·payload(객체) 네 칸뿐이다 */
|
||||
function makeMessage(session, type, payload) { return { wmve: 1, session: String(session || ''), type: String(type || ''), payload: payload && typeof payload === 'object' ? payload : {} }; }
|
||||
/** 봉투 검사(공통): wmve 판별값·세션·종류(방향별 목록)·payload(배열 아닌 객체) */
|
||||
function validateEnvelope(d, o) {
|
||||
if (!d || typeof d !== 'object' || d.wmve !== 1) return false;
|
||||
if (typeof d.session !== 'string' || d.session !== o.session) return false;
|
||||
if (typeof d.type !== 'string' || !(MESSAGE_TYPES[o.direction] || []).includes(d.type)) return false;
|
||||
if (!d.payload || typeof d.payload !== 'object' || Array.isArray(d.payload)) return false;
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* 받은 **창(window) message** 이벤트가 우리 규약에 맞는지. 보낸 창(source)·출처(origin)·세션·형식·종류를 모두 확인한다. 하나라도 어긋나면 false.
|
||||
* 캔버스는 sandbox(allow-same-origin 없음) 라 출처가 'null' 이다 — 부모는 expectedOrigin:'null' 과 iframe 의 contentWindow 를 넘긴다.
|
||||
* 연결 규약(1.8.0 검토 수정)에서는 창 메시지는 캔버스 → 부모의 첫 'ready'(MessagePort 동봉) 한 번만 쓰고, 그 뒤 모든 명령·이벤트는 포트로 오간다(validatePortMessage).
|
||||
* @param {{data:any, source:any, origin:string}} ev
|
||||
* @param {{expectedSource:any, expectedOrigin:string, session:string, direction:'fromCanvas'|'toCanvas'}} o
|
||||
*/
|
||||
function validateMessage(ev, o) {
|
||||
if (!ev || !o || !o.session) return false;
|
||||
if (!o.expectedSource || ev.source !== o.expectedSource) return false;
|
||||
if (String(ev.origin) !== String(o.expectedOrigin)) return false;
|
||||
return validateEnvelope(ev.data, o);
|
||||
}
|
||||
/**
|
||||
* **MessagePort** 로 받은 message 이벤트 검사. 포트 메시지에는 보낸 창·출처가 없다(포트 자체가 그것을 만든 문서에 묶여 있어 다른 문서가 이어받을 수 없다).
|
||||
* 그래서 봉투(세션·종류·payload)만 검사한다. 세션은 이중 확인용이다.
|
||||
* @param {{data:any}} ev
|
||||
* @param {{session:string, direction:'fromCanvas'|'toCanvas'}} o
|
||||
*/
|
||||
function validatePortMessage(ev, o) { if (!ev || !o || !o.session) return false; return validateEnvelope(ev.data, o); }
|
||||
|
||||
// ---------- 주소(URL) 정규화 — 서버(lib/visual-editor.js)와 화면·캔버스가 같은 규칙을 쓴다 ----------
|
||||
/**
|
||||
* 브라우저가 속성값에서 풀어 읽는 HTML 이름 엔티티 중 주소 스킴을 감추는 데 쓰일 만한 것. 숫자 엔티티(&#NN; &#xHH; 세미콜론 생략 포함)는 모두 푼다.
|
||||
* 여기 없는 이름 엔티티는 풀지 않고 그대로 두며, 그것이 스킴 자리에 남아 있으면 urlScheme 이 ambiguous(흐린 스킴)로 보아 거절한다 — 모르는 조합을 허용하지 않는다(fail-closed).
|
||||
*/
|
||||
const URL_ENTITIES = { colon: ':', Tab: '\t', NewLine: '\n', sol: '/', bsol: '\\', num: '#', period: '.', quest: '?', excl: '!', lpar: '(', rpar: ')', quot: '"', QUOT: '"', apos: "'", amp: '&', AMP: '&', lt: '<', LT: '<', gt: '>', GT: '>', nbsp: '\u00a0', ensp: '\u2002', emsp: '\u2003', thinsp: '\u2009', zwnj: '\u200c', zwj: '\u200d', lrm: '\u200e', rlm: '\u200f', ZeroWidthSpace: '\u200b', NoBreak: '\u2060', NegativeMediumSpace: '\u200b', equals: '=', semi: ';', plus: '+', comma: ',', verbar: '|', vert: '|', grave: '`', Hat: '^', lowbar: '_', dollar: '$', percnt: '%', ast: '*', midast: '*', commat: '@', lbrack: '[', lsqb: '[', rbrack: ']', rsqb: ']', lbrace: '{', lcub: '{', rbrace: '}', rcub: '}', tilde: '~' };
|
||||
const ENTITY_RE = /&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);?/g;
|
||||
/** 엔티티 한 번 풀기. 모르는 이름 엔티티는 원문 그대로 남긴다 */
|
||||
function decodeEntitiesOnce(s) {
|
||||
return String(s).replace(ENTITY_RE, (m, body) => {
|
||||
if (body[0] === '#') { const hex = body[1] === 'x' || body[1] === 'X'; const n = parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10); if (!Number.isFinite(n) || n < 0 || n > 0x10ffff) return ''; try { return String.fromCodePoint(n); } catch (_) { return ''; } }
|
||||
if (Object.prototype.hasOwnProperty.call(URL_ENTITIES, body)) return URL_ENTITIES[body];
|
||||
const low = body.toLowerCase(); for (const k of Object.keys(URL_ENTITIES)) if (k.toLowerCase() === low) return URL_ENTITIES[k];
|
||||
return m;
|
||||
});
|
||||
}
|
||||
/** 스킴 판별용 정규형: 엔티티(두 겹까지) 풀기 → 제어·공백·폭 없는 문자 제거 → 소문자. 저장되는 값은 바꾸지 않고 판별에만 쓴다 */
|
||||
function normalizeUrl(v) {
|
||||
let s = String(v == null ? '' : v); for (let i = 0; i < 2 && s.includes('&'); i++) s = decodeEntitiesOnce(s);
|
||||
return s.replace(/[\u0000-\u0020\u007f-\u00a0\u1680\u180e\u2000-\u200f\u2028-\u202f\u205f\u2060-\u2064\u3000\ufeff]/g, '').toLowerCase();
|
||||
}
|
||||
/**
|
||||
* 주소의 스킴 분석. { scheme: 'javascript' 등(없으면 ''), rest: 스킴 뒤, ambiguous: 스킴 자리(첫 ':' '/' '?' '#' 앞)에 풀리지 않은 '&' 가 남아 있고 뒤에 ':' 가 있는 경우 }
|
||||
* 예) 'java:script:x' → javascript · 'javascript:x' → javascript · 'java&bogus;script:x' → ambiguous · 'about.html?x:y' → 스킴 없음(상대 경로)
|
||||
*/
|
||||
function urlScheme(v) {
|
||||
const s = normalizeUrl(v); const head = s.split(/[/?#:]/)[0];
|
||||
const ambiguous = head.includes('&') && s.slice(head.length).includes(':');
|
||||
const m = /^([a-z][a-z0-9+.\-]*):/.exec(s); if (!m) return { scheme: '', rest: s, ambiguous };
|
||||
return { scheme: m[1], rest: s.slice(m[0].length), ambiguous };
|
||||
}
|
||||
const SCRIPT_SCHEMES = ['javascript', 'vbscript', 'livescript', 'mocha', 'ecmascript'];
|
||||
/** 실행·문서 주입이 가능한 주소인지(javascript: 류, data: 는 이미지만 허용, 흐린 스킴은 거절) */
|
||||
function isDangerousUrl(v) {
|
||||
const u = urlScheme(v); if (u.ambiguous) return true;
|
||||
if (SCRIPT_SCHEMES.includes(u.scheme)) return true;
|
||||
if (u.scheme === 'data') return !/^image\//.test(u.rest) || /script/.test(u.rest);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- 실행 취소 / 다시 실행 ----------
|
||||
/**
|
||||
* 본문 HTML 스냅샷 기록. reset(초기값) 뒤 push(새 스냅샷) 으로 쌓고 undo()/redo() 가 돌아갈 스냅샷을 돌려준다(없으면 null).
|
||||
* 같은 내용을 연달아 push 하면 기록하지 않는다. limit 를 넘으면 가장 오래된 것부터 버린다.
|
||||
* @param {number} limit
|
||||
*/
|
||||
function createHistory(limit) {
|
||||
const max = Math.max(2, Number(limit) || 100); let items = []; let idx = -1;
|
||||
return {
|
||||
reset(initial) { items = [initial]; idx = 0; },
|
||||
push(snap) { if (idx >= 0 && items[idx] === snap) return false; items = items.slice(0, idx + 1); items.push(snap); if (items.length > max) items.splice(0, items.length - max); idx = items.length - 1; return true; },
|
||||
undo() { if (idx <= 0) return null; idx--; return items[idx]; },
|
||||
redo() { if (idx >= items.length - 1) return null; idx++; return items[idx]; },
|
||||
canUndo() { return idx > 0; }, canRedo() { return idx < items.length - 1; },
|
||||
current() { return idx >= 0 ? items[idx] : null; }, size() { return items.length; },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 저장 상태 ----------
|
||||
const SAVE_LABELS = { clean: '변경 없음', dirty: '저장되지 않은 변경이 있습니다', saving: '저장 중…', saved: '저장됨', error: '저장 실패' };
|
||||
/**
|
||||
* 저장 상태 기계: clean → dirty → saving → saved | error. saving 중의 변경은 저장이 끝나면 dirty 로 남는다(잃어버리지 않는다).
|
||||
* label() 은 화면·aria-live 에 바로 쓸 한국어 문구.
|
||||
*/
|
||||
function createSaveState() {
|
||||
let st = 'clean'; let pending = false; let err = '';
|
||||
return {
|
||||
state() { return st; }, isDirty() { return st === 'dirty' || st === 'error' || (st === 'saving') || pending; },
|
||||
label() { return st === 'error' && err ? `${SAVE_LABELS.error} — ${err}` : SAVE_LABELS[st]; },
|
||||
markDirty() { if (st === 'saving') { pending = true; return; } st = 'dirty'; err = ''; },
|
||||
beginSave() { if (st !== 'dirty' && st !== 'error') return false; st = 'saving'; pending = false; err = ''; return true; },
|
||||
saveOk() { if (st !== 'saving') return; st = pending ? 'dirty' : 'saved'; pending = false; },
|
||||
saveFail(message) { if (st !== 'saving') return; st = 'error'; err = String(message || ''); pending = false; },
|
||||
reset() { st = 'clean'; pending = false; err = ''; },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 구성 요소(팔레트) ----------
|
||||
/** 팔레트에 보이는 구성 요소. html 은 스크립트·이벤트 속성·위험 주소가 없는 정적 HTML 이어야 하며 isSafeComponentHtml 로 검사된다 */
|
||||
const COMPONENTS = [
|
||||
{ id: 'section', label: '섹션(컨테이너)', hint: '안에 다른 요소를 담는 구역', html: '<section style="padding:40px 20px"><h2>새 섹션</h2><p>여기에 내용을 입력하세요.</p></section>' },
|
||||
{ id: 'heading', label: '제목', hint: '큰 글씨 제목(h2)', html: '<h2>새 제목</h2>' },
|
||||
{ id: 'paragraph', label: '문단', hint: '본문 글', html: '<p>여기에 내용을 입력하세요.</p>' },
|
||||
{ id: 'button', label: '버튼(링크)', hint: '눌러서 이동하는 링크 버튼', html: '<a href="#" style="display:inline-block;padding:12px 24px;background:#2f6fed;color:#fff;border-radius:8px;text-decoration:none">버튼</a>' },
|
||||
{ id: 'image', label: '이미지', hint: '이미지(주소·대체 글은 속성에서)', html: '<img src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23e5e7eb%22/%3E%3Ctext x=%22320%22 y=%22190%22 text-anchor=%22middle%22 font-size=%2228%22 fill=%22%236b7684%22 font-family=%22sans-serif%22%3E%EC%9D%B4%EB%AF%B8%EC%A7%80%3C/text%3E%3C/svg%3E" alt="이미지 설명" style="max-width:100%;height:auto">' },
|
||||
{ id: 'divider', label: '구분선', hint: '가로 선', html: '<hr>' },
|
||||
{ id: 'spacer', label: '여백', hint: '세로 빈 공간', html: '<div style="height:40px" aria-hidden="true"></div>' },
|
||||
];
|
||||
/** 구성 요소 HTML (없는 id 면 null) */
|
||||
function componentHtml(id) { const c = COMPONENTS.find((x) => x.id === id); return c ? c.html : null; }
|
||||
|
||||
/**
|
||||
* 팔레트 → 캔버스 끌어다 놓기의 dataTransfer 규약.
|
||||
* 캔버스는 출처 'null' 인 sandbox iframe(allow-same-origin 없음)이라 Chromium 은 다른 출처에서 넣은 전용 MIME('text/wm-ve-component')
|
||||
* 끌기 자료를 넘겨주지 않는다(종류 목록에서도 사라진다). 그래서 부모는 전용 MIME 과 **함께** 표준 'text/plain' 에 이름 공간을 붙인
|
||||
* 값 'wm-ve-component:<id>' 를 넣고, 캔버스는 전용 MIME 을 우선 읽되 없으면 text/plain 이 이 형식에 **정확히** 맞고 id 가 알려진
|
||||
* 구성 요소일 때만 받는다. 임의 글·주소·HTML·모르는 id 는 null → 캔버스는 아무것도 넣지 않는다.
|
||||
* dragover 때는 브라우저가 getData 를 막으므로 isComponentDrag 로 종류만 보고 끌기를 인식하고, drop 때 dragComponentId 로 검증한다.
|
||||
*/
|
||||
const COMPONENT_MIME = 'text/wm-ve-component';
|
||||
const COMPONENT_TEXT_PREFIX = 'wm-ve-component:';
|
||||
/** 부모 dragstart 가 text/plain 에 넣는 값 */
|
||||
function componentDragText(id) { return COMPONENT_TEXT_PREFIX + String(id || ''); }
|
||||
/** 끌기 종류 목록(dataTransfer.types)에 구성 요소 끌기일 수 있는 종류가 있는가 */
|
||||
function isComponentDrag(types) { let t = []; try { t = Array.from(types || []); } catch (_) { return false; } return t.includes(COMPONENT_MIME) || t.includes('text/plain'); }
|
||||
/** 알려진 구성 요소 id 인가 */
|
||||
function isComponentId(id) { return typeof id === 'string' && COMPONENTS.some((c) => c.id === id); }
|
||||
|
||||
/**
|
||||
* 팔레트 → 캔버스 **포인터 끌기 다리**(주 경로).
|
||||
* 실제 브라우저에서 HTML5 끌어다 놓기는 부모 문서에서 sandbox(출처 null) iframe 으로 넘어가지 않는다(dragenter/dragover/drop 이 캔버스에 한 번도 오지 않음 — 진단으로 확인).
|
||||
* 그래서 부모가 팔레트 항목의 포인터(마우스·펜)를 직접 추적한다: pointerdown 뒤 POINTER_DRAG_THRESHOLD px 넘게 움직이면 끌기 시작, 포인터 캡처로 iframe 위에서도
|
||||
* pointermove 를 계속 받고, 좌표를 #frame 의 화면 사각형과 비교해 **안쪽일 때만** 검증된 메시지 paletteDragAt(iframe 상대 x·y)·paletteDropAt 을 보낸다.
|
||||
* 캔버스는 좌표를 canvasPoint 로 다시 검증해 dropTargetAt → showDrop / insertComponent 를 스스로 수행한다(부모가 DOM 위치를 지정하지 않는다).
|
||||
*/
|
||||
const POINTER_DRAG_THRESHOLD = 4;
|
||||
/**
|
||||
* 화면 좌표 → iframe 안쪽(콘텐츠 상자) 상대 좌표. rect 는 iframe 요소의 getBoundingClientRect(), box 는 { left: clientLeft, top: clientTop, width: clientWidth, height: clientHeight }
|
||||
* (테두리·스크롤바 보정, 생략하면 테두리 0·rect 크기). 안쪽이 아니면 inside:false.
|
||||
*/
|
||||
function pointInFrame(clientX, clientY, rect, box) {
|
||||
const b = box || {}; const bl = Number(b.left) || 0; const bt = Number(b.top) || 0; const w = Number(b.width) || Number(rect.width) || 0; const h = Number(b.height) || Number(rect.height) || 0;
|
||||
const x = Math.round(clientX - rect.left - bl); const y = Math.round(clientY - rect.top - bt);
|
||||
return { inside: x >= 0 && y >= 0 && x < w && y < h, x, y };
|
||||
}
|
||||
/** 캔버스가 받은 좌표 검증: 유한한 숫자이고 자기 뷰포트(0..w, 0..h) 안일 때만 {x, y}, 아니면 null */
|
||||
function canvasPoint(x, y, w, h) {
|
||||
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
if (x < 0 || y < 0 || x > w || y > h) return null;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** drop 의 dataTransfer 에서 검증된 구성 요소 id 를 꺼낸다(없거나 틀리면 null). 전용 MIME 값이 있으면 그 값만 믿는다 */
|
||||
function dragComponentId(dt) {
|
||||
if (!dt || typeof dt.getData !== 'function') return null;
|
||||
const known = (v) => (COMPONENTS.some((c) => c.id === v) ? v : null);
|
||||
let own = ''; try { own = dt.getData(COMPONENT_MIME) || ''; } catch (_) { own = ''; }
|
||||
if (own) return known(String(own));
|
||||
let text = ''; try { text = dt.getData('text/plain') || ''; } catch (_) { return null; }
|
||||
text = String(text); if (!text.startsWith(COMPONENT_TEXT_PREFIX)) return null;
|
||||
const id = text.slice(COMPONENT_TEXT_PREFIX.length); if (!/^[a-z]+$/.test(id)) return null;
|
||||
return known(id);
|
||||
}
|
||||
/** 구성 요소 HTML 안전 규칙: script/iframe/object/embed/style 요소 없음, on* 속성 없음, javascript:/vbscript:/data:text 주소 없음 */
|
||||
function isSafeComponentHtml(html) {
|
||||
const s = String(html || '');
|
||||
if (/<\s*(script|iframe|frame|object|embed|style|link|meta|base|form)\b/i.test(s)) return false;
|
||||
if (/<\s*(?:[a-z0-9_-]+:)?(animate|set|animateMotion|animateTransform|animateColor|discard|mpath)\b/i.test(s)) return false; // SVG SMIL 변이 요소(href 를 javascript: 로 바꿀 수 있다) — 서버 저장 검증과 같은 묶음, 접두어 형태 포함
|
||||
if (/\son[a-z]+\s*=/i.test(s)) return false;
|
||||
if (/(href|src|action|srcset|poster)\s*=\s*["']?\s*(javascript|vbscript|data:text)/i.test(s.replace(/\s+/g, ' '))) return false;
|
||||
const re = /\s(href|src|action|formaction|srcset|poster)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi; let m;
|
||||
while ((m = re.exec(s))) if (isDangerousUrl(m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]))) return false; // 엔티티·제어 문자로 감춘 스킴까지(서버와 같은 규칙)
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- 재배치 계산 ----------
|
||||
/** 배열의 from 위치 항목을 to 위치로 옮긴 새 배열(범위를 벗어나면 그대로) */
|
||||
function moveIndex(arr, from, to) { const a = arr.slice(); if (from < 0 || from >= a.length || to < 0 || to >= a.length) return a; const [x] = a.splice(from, 1); a.splice(to, 0, x); return a; }
|
||||
/**
|
||||
* 세로로 늘어선 형제 요소들의 경계 상자(top/bottom)와 포인터 y 로 "몇 번째 앞에 놓을지" 를 정한다. 각 요소의 세로 중간선을 넘으면 그 다음 자리.
|
||||
* @param {{top:number,bottom:number}[]} rects
|
||||
* @param {number} y
|
||||
* @returns {number} 0..rects.length
|
||||
*/
|
||||
function dropIndexFor(rects, y) { let idx = 0; for (const r of rects) { if (y > (r.top + r.bottom) / 2) idx++; else break; } return idx; }
|
||||
|
||||
// ---------- 속성 편집 규칙 ----------
|
||||
const TEXT_TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'button', 'span', 'li', 'td', 'th', 'blockquote', 'label', 'small', 'strong', 'em', 'b', 'i', 'figcaption', 'summary', 'dt', 'dd', 'address', 'cite', 'legend'];
|
||||
const BLOCK_TAGS = ['section', 'div', 'article', 'aside', 'header', 'footer', 'nav', 'main', 'ul', 'ol', 'figure', 'form', 'table', 'details', 'hr', 'img', 'picture', 'video', 'audio', 'blockquote', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li'];
|
||||
const LABELS = { h1: '제목 1', h2: '제목 2', h3: '제목 3', h4: '제목 4', h5: '제목 5', h6: '제목 6', p: '문단', a: '링크', button: '버튼', img: '이미지', section: '섹션', div: '블록', article: '글', aside: '보조 영역', header: '머리글', footer: '바닥글', nav: '메뉴', main: '본문 영역', ul: '목록', ol: '번호 목록', li: '목록 항목', span: '글 조각', hr: '구분선', figure: '그림', figcaption: '그림 설명', table: '표', form: '폼', input: '입력칸', textarea: '여러 줄 입력', select: '선택', label: '라벨', strong: '강조', em: '강조(기울임)', blockquote: '인용', video: '동영상', iframe: '삽입 프레임', svg: '벡터 그림' };
|
||||
/** 요소 이름의 한국어 표시 */
|
||||
function labelFor(tag) { const t = String(tag || '').toLowerCase(); return LABELS[t] || `요소(${t || '?'})`; }
|
||||
/**
|
||||
* 요소 종류별로 속성 창에 보일 항목: text(글) · href(링크 주소) · src/alt(이미지) · align(정렬) · color(글자색) · background(배경색) · padding/margin(여백)
|
||||
* @param {string} tag
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function propsFor(tag) {
|
||||
const t = String(tag || '').toLowerCase(); const out = [];
|
||||
if (TEXT_TAGS.includes(t)) out.push('text');
|
||||
if (t === 'a' || t === 'button') out.push('href');
|
||||
if (t === 'img') out.push('src', 'alt');
|
||||
if (t !== 'hr' && t !== 'br' && t !== 'img') out.push('align', 'color');
|
||||
out.push('background', 'padding', 'margin');
|
||||
return out;
|
||||
}
|
||||
/**
|
||||
* 사용자가 속성 창에 넣은 주소 검사. kind 'link' 는 http(s)·mailto·tel·sms·상대 경로·#앵커만, 'image' 는 거기에 data:image/* 를 더 허용한다.
|
||||
* @returns {{ok: boolean, value: string, error?: string}}
|
||||
*/
|
||||
function safeUrlInput(value, kind) {
|
||||
const v = String(value == null ? '' : value).trim();
|
||||
const u = urlScheme(v); const scheme = u.scheme; // 엔티티(: j …)·제어 문자·폭 없는 문자로 감춘 스킴도 서버와 같은 규칙으로 판별
|
||||
if (u.ambiguous) return { ok: false, value: v, error: '주소에 알 수 없는 문자 참조(&…;)가 섞여 있어 넣을 수 없습니다. 주소를 그대로 입력하세요' };
|
||||
if (SCRIPT_SCHEMES.includes(scheme)) return { ok: false, value: v, error: '스크립트 주소(javascript: 등)는 넣을 수 없습니다' };
|
||||
if (scheme === 'data') { if (kind === 'image' && /^image\/(png|jpe?g|gif|webp|svg\+xml|avif)[;,]/.test(u.rest) && !/script/.test(u.rest)) return { ok: true, value: v }; return { ok: false, value: v, error: 'data: 주소는 이미지(data:image/…)에만 쓸 수 있습니다' }; }
|
||||
if (scheme && !['http', 'https', 'mailto', 'tel', 'sms'].includes(scheme)) return { ok: false, value: v, error: `허용되지 않는 주소 형식(${scheme}:)입니다. http(s)·mailto·tel 또는 상대 경로를 쓰세요` };
|
||||
return { ok: true, value: v };
|
||||
}
|
||||
|
||||
return { VERSION, MESSAGE_TYPES, makeMessage, validateMessage, validatePortMessage, createHistory, createSaveState, SAVE_LABELS, COMPONENTS, componentHtml, isSafeComponentHtml, isComponentId, COMPONENT_MIME, componentDragText, isComponentDrag, dragComponentId, POINTER_DRAG_THRESHOLD, pointInFrame, canvasPoint, moveIndex, dropIndexFor, TEXT_TAGS, BLOCK_TAGS, propsFor, labelFor, safeUrlInput, normalizeUrl, urlScheme, isDangerousUrl };
|
||||
}));
|
||||
@@ -0,0 +1,247 @@
|
||||
'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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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() }) };
|
||||
})();
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* public/wm-ve-canvas.js — v1.8.0 직접 편집 캔버스 런타임. 서버(lib/visual-editor.prepareCanvas)가 편집 캔버스 문서의 <head> 에 nonce 와 함께 인라인으로 넣는다.
|
||||
* 캔버스 = 빌더 미리보기 iframe(sandbox, allow-same-origin 없음 → 출처 'null', 쿠키·저장소·빌더 API 접근 불가) 안의 srcdoc 문서.
|
||||
* 페이지 자체 스크립트는 모두 type="text/x-wm-ve-disabled" 로 바뀌어 있고 CSP 가 nonce 없는 스크립트를 막으므로, 이 문서에서 실행되는 코드는 이 런타임(+ 공용 핵심)만이다.
|
||||
*
|
||||
* 하는 일
|
||||
* - 요소 선택(클릭)·마우스 올림 표시, 글 요소의 직접 편집(contenteditable, 붙여넣기는 글자만), 선택 요소 도구(위/아래 이동·복제·삭제·끌기 손잡이)
|
||||
* - 기존 블록 끌어 옮기기(포인터 기반, 같은 문서 안), 팔레트(부모 창)에서 끌어온 구성 요소 놓기 — 주 경로: 부모가 iframe 상대 좌표를 보내는 포인터 끌기 다리(paletteDragAt/paletteDropAt, 위치 계산·삽입은 여기서),
|
||||
* 대체 경로: HTML5 DnD(전용 MIME 'text/wm-ve-component' 우선 + text/plain 'wm-ve-component:<id>'; 실제 브라우저에서는 sandbox 경계를 넘지 못해 포인터 다리가 주 경로다)
|
||||
* - 부모 창과의 연결 규약: 첫 ready 창 메시지(부모 출처로, MessagePort 동봉) 뒤 모든 명령·이벤트는 그 포트로만(WMVisualCore.validatePortMessage: 세션·종류·payload). 창 메시지는 듣지 않는다.
|
||||
* 변경마다 본문(body innerHTML)을 돌려준다.
|
||||
* - 링크·area·버튼·폼의 원래 동작(이동·제출)은 편집·미리보기 모두에서 막는다(+ 서버가 넣는 base target=_blank·CSP form-action 'none'·meta refresh 비활성화). 저장은 부모 창이 한다(캔버스는 서버와 통신하지 않는다).
|
||||
* 보존 규칙: 페이지의 원래 코드는 바꾸지 않고, 편집기가 더한 것은 data-wm-ve-* 속성·wm-ve-* 클래스·[data-wm-ve-ui] 요소뿐이다. 본문을 돌려줄 때 그것들을 지운다(서버도 다시 지운다).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const cfg = window.__WM_VE__ || {}; const core = window.WMVisualCore;
|
||||
if (!cfg.session || !cfg.parentOrigin || !core) return;
|
||||
const SESSION = String(cfg.session); const PARENT_ORIGIN = String(cfg.parentOrigin); const MARK = 'data-wm-ve-';
|
||||
/** 보존 속성(서버가 원래 이름으로 되돌린다) — 본문을 돌려줄 때 지우지 않는다 */
|
||||
const KEEP_MARK = /^data-wm-ve-(on-|href$|src$|srcdoc$|data$|http-equiv$|orig-type$|action$|formaction$|poster$|srcset$|background$|xlink:href$|ping$|cite$|longdesc$)/;
|
||||
const TEXT_TAGS = new Set(core.TEXT_TAGS);
|
||||
let selected = null; // 선택된 요소
|
||||
let editing = null; // 글 편집 중인 요소
|
||||
let mode = 'edit'; // 'edit' | 'preview'(선택·편집 끔)
|
||||
let changeTimer = null; // 글 입력 디바운스
|
||||
|
||||
// ---------- 부모 창과의 통신(연결 규약, 1.8.0 검토 수정) ----------
|
||||
// 이 문서가 MessageChannel 을 만들고 port2 를 **첫 ready 창 메시지에 동봉** 해 부모(출처 고정)에게 넘긴다. 그 뒤 모든 명령(부모→캔버스)·이벤트(캔버스→부모)는 port1 로만 오간다.
|
||||
// 포트는 그것을 만든 이 문서에 묶여 있다: iframe 안의 문서가 바뀌면(링크·area·폼 등으로 이동) 새 문서는 포트를 이어받을 수 없고, 부모도 연결 뒤에는 창 메시지를 받지 않으므로
|
||||
// 새 문서가 세션을 알아내거나 changed/hotkey 를 꾸밀 길이 없다. 이 문서는 창(window) message 를 **전혀 듣지 않는다** — 세션이 든 명령이 targetOrigin '*' 로 오가는 일도 없다.
|
||||
const channel = new MessageChannel(); const port = channel.port1;
|
||||
/** 부모에게 이벤트를 보낸다(봉투: wmve·session·type·payload) — 연결된 포트로만 */
|
||||
function send(type, payload) { try { port.postMessage(core.makeMessage(SESSION, type, payload || {})); } catch (_) {} }
|
||||
port.onmessage = (e) => {
|
||||
if (!core.validatePortMessage(e, { session: SESSION, direction: 'toCanvas' })) return;
|
||||
const p = e.data.payload;
|
||||
try {
|
||||
switch (e.data.type) {
|
||||
case 'init': setMode(p.mode === 'preview' ? 'preview' : 'edit'); send('body', { body: serialize() }); break;
|
||||
case 'requestBody': send('body', { body: serialize() }); break;
|
||||
case 'select': select(elAt(p.path)); break;
|
||||
case 'move': if (selected) moveSelected(p.dir === 'up' ? -1 : 1); break;
|
||||
case 'duplicate': duplicateSelected(); break;
|
||||
case 'delete': deleteSelected(); break;
|
||||
case 'insert': insertComponent(String(p.component || ''), p.where || 'after'); break;
|
||||
case 'setProps': applyProps(p.props || {}); break;
|
||||
case 'applyHtml': applyHtml(String(p.body || '')); break;
|
||||
case 'setMode': setMode(p.mode === 'preview' ? 'preview' : 'edit'); break;
|
||||
case 'paletteDragAt': paletteDragAt(p); break;
|
||||
case 'paletteDragCancel': if (ui.drop) ui.drop.hidden = true; break;
|
||||
case 'paletteDropAt': paletteDropAt(p); break;
|
||||
default: break;
|
||||
}
|
||||
} catch (err) { send('error', { message: String(err && err.message || err) }); }
|
||||
};
|
||||
|
||||
// ---------- 요소 식별 ----------
|
||||
const isUi = (el) => !!(el && el.nodeType === 1 && (el.hasAttribute(MARK + 'ui') || el.closest('[' + MARK + 'ui]')));
|
||||
/** body 아래 요소 자식(편집기 UI 제외) 목록 */
|
||||
const kids = (el) => [...el.children].filter((c) => !c.hasAttribute(MARK + 'ui'));
|
||||
/** 요소 → body 기준 자식 번호 경로(예: [1,0,2]). 부모 창이 요소를 가리키는 데 쓴다 */
|
||||
function pathOf(el) { const path = []; let cur = el; while (cur && cur !== document.body) { const p = cur.parentElement; if (!p) return null; path.unshift(kids(p).indexOf(cur)); cur = p; } return cur === document.body ? path : null; }
|
||||
function elAt(path) { if (!Array.isArray(path)) return null; let cur = document.body; for (const i of path) { cur = kids(cur)[i]; if (!cur) return null; } return cur === document.body ? null : cur; }
|
||||
/** 선택 대상이 될 수 있는 요소: body 아래의 일반 요소(편집기 UI·script·style 제외) */
|
||||
function selectable(el) { if (!el || el.nodeType !== 1 || el === document.body || el === document.documentElement || isUi(el)) return null; if (['script', 'style', 'link', 'meta', 'base', 'template'].includes(el.tagName.toLowerCase())) return null; return el; }
|
||||
/** 끌어 옮기기·놓기의 기준이 되는 블록(선택 요소 또는 가장 가까운 블록 조상) */
|
||||
function blockOf(el) { let cur = el; while (cur && cur !== document.body) { if (core.BLOCK_TAGS.includes(cur.tagName.toLowerCase()) || getComputedStyle(cur).display !== 'inline') return cur; cur = cur.parentElement; } return null; }
|
||||
|
||||
// ---------- 본문 직렬화 ----------
|
||||
/** body innerHTML 에서 편집기 흔적을 지운 본문. 보존 속성(KEEP_MARK)과 원래 코드는 그대로 */
|
||||
function serialize() {
|
||||
const clone = document.body.cloneNode(true);
|
||||
clone.querySelectorAll('[' + MARK + 'ui]').forEach((n) => n.remove());
|
||||
clone.querySelectorAll('*').forEach((n) => {
|
||||
if (n.hasAttribute(MARK + 'editable')) { n.removeAttribute('contenteditable'); if (n.hasAttribute(MARK + 'spellcheck')) n.removeAttribute('spellcheck'); }
|
||||
for (const a of [...n.attributes]) if (a.name.startsWith(MARK) && !KEEP_MARK.test(a.name)) n.removeAttribute(a.name);
|
||||
if (n.classList && [...n.classList].some((c) => c.startsWith('wm-ve-'))) { [...n.classList].filter((c) => c.startsWith('wm-ve-')).forEach((c) => n.classList.remove(c)); if (!n.classList.length) n.removeAttribute('class'); }
|
||||
});
|
||||
return clone.innerHTML;
|
||||
}
|
||||
/** 변경을 부모에 알린다(실행 취소 기록·저장 상태는 부모가 관리) */
|
||||
function changed(reason) { clearTimeout(changeTimer); changeTimer = null; send('changed', { body: serialize(), reason: String(reason || '') }); }
|
||||
function changedSoon(reason) { clearTimeout(changeTimer); changeTimer = setTimeout(() => changed(reason), 300); }
|
||||
/** 부모가 보낸 본문(실행 취소·다시 실행·버리기)으로 바꿔 끼운다 — 편집기 UI 는 다시 만든다 */
|
||||
function applyHtml(body) { endEdit(false); selected = null; document.body.innerHTML = body; mountUi(); renderOverlay(); send('selected', { path: null }); }
|
||||
|
||||
// ---------- 편집기 UI(덧씌움) ----------
|
||||
let ui = {};
|
||||
function mountUi() {
|
||||
const st = document.createElement('style'); st.setAttribute(MARK + 'ui', '');
|
||||
st.textContent = `
|
||||
[${MARK}hover]{outline:1px dashed #7c9cf0 !important;outline-offset:1px}
|
||||
[${MARK}selected]{outline:2px solid #2f6fed !important;outline-offset:2px}
|
||||
[${MARK}editable]{outline:2px solid #1a8f5a !important;outline-offset:2px;cursor:text;min-height:1em}
|
||||
/* hidden 계약: 아래 .wm-ve-tools{display:flex} 가 UA 의 [hidden]{display:none} 을 덮어 선택 전에도 도구 줄이 뜨지 않도록 */
|
||||
.wm-ve-tools[hidden],.wm-ve-drop[hidden]{display:none !important}
|
||||
.wm-ve-tools{position:absolute;z-index:2147483000;display:flex;gap:2px;background:#1f2933;color:#fff;border-radius:6px;padding:3px;font:12px/1 -apple-system,"Segoe UI","Noto Sans KR",sans-serif;box-shadow:0 4px 14px rgba(0,0,0,.25)}
|
||||
.wm-ve-tools button{all:unset;cursor:pointer;padding:4px 7px;border-radius:4px;color:#fff;font:inherit;line-height:1}
|
||||
.wm-ve-tools button:hover,.wm-ve-tools button:focus-visible{background:#2f6fed;outline:2px solid #fff}
|
||||
.wm-ve-tools .wm-ve-tag{padding:4px 6px;color:#cbd5e1;border-right:1px solid #475569;margin-right:2px}
|
||||
.wm-ve-tools .wm-ve-grip{cursor:grab}
|
||||
.wm-ve-drop{position:absolute;z-index:2147483000;height:0;border-top:3px solid #2f6fed;pointer-events:none}
|
||||
.wm-ve-drop::before{content:"";position:absolute;left:-6px;top:-7px;width:10px;height:10px;border-radius:50%;background:#2f6fed}
|
||||
.wm-ve-dragging{opacity:.5}
|
||||
body.wm-ve-preview [${MARK}hover]{outline:none !important}`;
|
||||
document.head.appendChild(st);
|
||||
const tools = document.createElement('div'); tools.className = 'wm-ve-tools'; tools.setAttribute(MARK + 'ui', ''); tools.setAttribute('role', 'toolbar'); tools.setAttribute('aria-label', '선택한 요소 도구'); tools.hidden = true;
|
||||
const mk = (label, title, fn, cls) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.title = title; b.setAttribute('aria-label', title); if (cls) b.className = cls; b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); fn(); }); tools.appendChild(b); return b; };
|
||||
ui.tag = document.createElement('span'); ui.tag.className = 'wm-ve-tag'; tools.appendChild(ui.tag);
|
||||
ui.grip = mk('✥', '끌어서 옮기기 (키보드: Alt+↑/↓)', () => {}, 'wm-ve-grip'); ui.grip.setAttribute('aria-hidden', 'true'); ui.grip.tabIndex = -1;
|
||||
mk('↑', '위로 이동 (Alt+↑)', () => moveSelected(-1)); mk('↓', '아래로 이동 (Alt+↓)', () => moveSelected(1));
|
||||
mk('⧉', '복제 (Ctrl+D)', duplicateSelected); mk('✕', '삭제 (Delete)', deleteSelected);
|
||||
document.body.appendChild(tools); ui.tools = tools;
|
||||
const drop = document.createElement('div'); drop.className = 'wm-ve-drop'; drop.setAttribute(MARK + 'ui', ''); drop.hidden = true; document.body.appendChild(drop); ui.drop = drop;
|
||||
ui.grip.addEventListener('pointerdown', startPointerDrag);
|
||||
}
|
||||
/** 선택 테두리 위의 도구 막대 위치를 선택 요소에 맞춘다 */
|
||||
function renderOverlay() {
|
||||
if (!ui.tools) return;
|
||||
if (!selected || !document.body.contains(selected) || mode !== 'edit') { ui.tools.hidden = true; return; }
|
||||
const r = selected.getBoundingClientRect(); const top = Math.max(2, r.top + window.scrollY - 30); const left = Math.max(2, r.left + window.scrollX);
|
||||
ui.tools.style.top = top + 'px'; ui.tools.style.left = left + 'px'; ui.tag.textContent = core.labelFor(selected.tagName); ui.tools.hidden = false;
|
||||
}
|
||||
window.addEventListener('scroll', renderOverlay, true); window.addEventListener('resize', renderOverlay);
|
||||
|
||||
// ---------- 선택 ----------
|
||||
/** 선택한 요소의 정보를 부모 속성 창에 보낸다 */
|
||||
function describe(el) {
|
||||
if (!el) return { path: null };
|
||||
const cs = getComputedStyle(el); const sib = kids(el.parentElement); const i = sib.indexOf(el); const tag = el.tagName.toLowerCase();
|
||||
return { path: pathOf(el), tag, label: core.labelFor(tag), isText: TEXT_TAGS.has(tag), hasChildren: el.children.length > 0, text: el.textContent || '', href: el.getAttribute('href') || '', src: el.getAttribute('src') || '', alt: el.getAttribute('alt') || '',
|
||||
style: { align: el.style.textAlign || cs.textAlign, color: el.style.color || '', background: el.style.backgroundColor || '', padding: el.style.padding || '', margin: el.style.margin || '' },
|
||||
canUp: i > 0, canDown: i >= 0 && i < sib.length - 1, parentPath: el.parentElement && el.parentElement !== document.body ? pathOf(el.parentElement) : null, props: core.propsFor(tag) };
|
||||
}
|
||||
function select(el) {
|
||||
if (editing && el !== editing) endEdit(true);
|
||||
if (selected) selected.removeAttribute(MARK + 'selected');
|
||||
selected = selectable(el); if (selected) selected.setAttribute(MARK + 'selected', '');
|
||||
renderOverlay(); send('selected', describe(selected));
|
||||
}
|
||||
/** 글 요소 직접 편집 시작: contenteditable 을 켜고 포커스. 붙여넣기는 글자만 들어가게 한다 */
|
||||
function startEdit(el) {
|
||||
if (!el || !TEXT_TAGS.has(el.tagName.toLowerCase()) || mode !== 'edit') return;
|
||||
if (editing && editing !== el) endEdit(true);
|
||||
editing = el; el.setAttribute(MARK + 'editable', ''); el.setAttribute('contenteditable', 'true'); if (!el.hasAttribute('spellcheck')) { el.setAttribute('spellcheck', 'false'); el.setAttribute(MARK + 'spellcheck', ''); }
|
||||
el.focus(); renderOverlay();
|
||||
}
|
||||
function endEdit(notify) {
|
||||
if (!editing) return; const el = editing; editing = null;
|
||||
el.removeAttribute('contenteditable'); el.removeAttribute(MARK + 'editable'); if (el.hasAttribute(MARK + 'spellcheck')) { el.removeAttribute('spellcheck'); el.removeAttribute(MARK + 'spellcheck'); }
|
||||
if (notify && changeTimer) changed('text');
|
||||
if (el === selected) send('selected', describe(el));
|
||||
}
|
||||
function setMode(m) { mode = m; document.body.classList.toggle('wm-ve-preview', m === 'preview'); if (m !== 'edit') { endEdit(true); select(null); } renderOverlay(); }
|
||||
|
||||
// ---------- 이동·복제·삭제·삽입·속성 ----------
|
||||
function moveSelected(dir) {
|
||||
if (!selected) return; const sib = kids(selected.parentElement); const i = sib.indexOf(selected); const j = i + dir; if (j < 0 || j >= sib.length) return;
|
||||
if (dir < 0) selected.parentElement.insertBefore(selected, sib[j]); else selected.parentElement.insertBefore(sib[j], selected);
|
||||
renderOverlay(); send('selected', describe(selected)); changed('move');
|
||||
}
|
||||
function duplicateSelected() {
|
||||
if (!selected) return; endEdit(true); const c = selected.cloneNode(true); c.removeAttribute(MARK + 'selected'); c.querySelectorAll('[' + MARK + 'ui]').forEach((n) => n.remove());
|
||||
c.removeAttribute('id'); c.querySelectorAll('[id]').forEach((n) => n.removeAttribute('id')); // id 는 문서에서 하나여야 한다: 뿌리뿐 아니라 자손의 id 도 지운다(원본은 그대로)
|
||||
selected.after(c); select(c); changed('duplicate');
|
||||
}
|
||||
function deleteSelected() {
|
||||
if (!selected) return; endEdit(false); const next = selected.nextElementSibling && selectable(selected.nextElementSibling) ? selected.nextElementSibling : selected.parentElement; const el = selected; selected = null; el.remove();
|
||||
select(next && next !== document.body ? next : null); changed('delete');
|
||||
}
|
||||
/** 팔레트 구성 요소 삽입. where: 'after'(선택 요소 뒤) | 'inside'(선택 컨테이너 안 끝) | 'end'(본문 끝) | {path, before} */
|
||||
function insertComponent(id, where) {
|
||||
const html = core.componentHtml(id); if (!html || !core.isSafeComponentHtml(html)) throw new Error('알 수 없는 구성 요소입니다');
|
||||
const tpl = document.createElement('template'); tpl.innerHTML = html; const node = tpl.content.firstElementChild; if (!node) return;
|
||||
placeNode(node, where); select(node); changed('insert'); node.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
function placeNode(node, where) {
|
||||
if (where && typeof where === 'object' && Array.isArray(where.path)) { const ref = elAt(where.path); if (ref) { if (where.before) ref.before(node); else ref.after(node); return; } }
|
||||
if (where === 'inside' && selected && !TEXT_TAGS.has(selected.tagName.toLowerCase()) && selected.tagName.toLowerCase() !== 'img') { selected.appendChild(node); return; }
|
||||
if ((where === 'after' || where === 'inside') && selected) { selected.after(node); return; }
|
||||
// 본문 끝: 끝에 몰려 있는 스크립트(비활성화된)보다 앞에 둔다
|
||||
const ks = kids(document.body); let anchor = null; for (let i = ks.length - 1; i >= 0 && ks[i].tagName.toLowerCase() === 'script'; i--) anchor = ks[i];
|
||||
if (anchor) anchor.before(node); else document.body.appendChild(node);
|
||||
}
|
||||
/** 속성 창에서 보낸 값 적용. 주소는 안전 규칙을 다시 검사한다(부모도 검사하지만 캔버스가 마지막 방어선) */
|
||||
function applyProps(p) {
|
||||
if (!selected) return; const el = selected; const tag = el.tagName.toLowerCase();
|
||||
if (typeof p.text === 'string' && TEXT_TAGS.has(tag) && el.children.length === 0) el.textContent = p.text;
|
||||
if (typeof p.href === 'string' && (tag === 'a' || tag === 'button')) { const r = core.safeUrlInput(p.href, 'link'); if (!r.ok) throw new Error(r.error); if (tag === 'a') { if (r.value) el.setAttribute('href', r.value); else el.removeAttribute('href'); } }
|
||||
if (typeof p.src === 'string' && tag === 'img') { const r = core.safeUrlInput(p.src, 'image'); if (!r.ok) throw new Error(r.error); el.setAttribute('src', r.value); }
|
||||
if (typeof p.alt === 'string' && tag === 'img') el.setAttribute('alt', p.alt);
|
||||
const css = (k, v) => { if (typeof v !== 'string') return; if (/expression|javascript|url\s*\(|behavior|@import/i.test(v)) throw new Error('허용되지 않는 스타일 값입니다'); el.style[k] = v; };
|
||||
css('textAlign', p.align); css('color', p.color); css('backgroundColor', p.background); css('padding', p.padding); css('margin', p.margin);
|
||||
if (el.getAttribute('style') === '') el.removeAttribute('style');
|
||||
renderOverlay(); send('selected', describe(el)); changed('props');
|
||||
}
|
||||
|
||||
// ---------- 포인터·키보드 ----------
|
||||
/** 캔버스 안에서 문서 이동·제출로 이어질 수 있는 활성화 요소. 편집·미리보기 어느 모드에서도 기본 동작을 막는다(키보드 Enter/Space 활성화도 click 으로 온다). 폼 submit·meta refresh·base target 은 따로 막는다 */
|
||||
const NAV_ACTIVATORS = 'a,area,button,input[type=submit],input[type=image]';
|
||||
document.addEventListener('click', (e) => {
|
||||
const t = e.target && e.target.closest ? e.target : (e.target && e.target.parentElement);
|
||||
if (t && t.closest && t.closest(NAV_ACTIVATORS)) e.preventDefault(); // 이 문서는 편집 캔버스다: 어디로도 이동하지 않는다
|
||||
if (mode !== 'edit') return;
|
||||
if (isUi(e.target)) return;
|
||||
if (t && t.closest && t.closest('summary')) e.preventDefault(); // 편집 중 details 토글 대신 선택
|
||||
const el = selectable(t); if (!el) return;
|
||||
if (editing === el) return; // 편집 중인 글 안의 클릭은 커서 이동
|
||||
select(el);
|
||||
}, true);
|
||||
// 가운데 버튼 클릭은 click 이 아니라 auxclick 으로 오고 기본 동작이 "새 탭에서 열기" 다 — 실제 Chrome 에서 sandbox(allow-popups 없음) 프레임의 링크 가운데 클릭이 새 페이지를 만든 것을 게이트가 확인했다.
|
||||
document.addEventListener('auxclick', (e) => { const t = e.target && e.target.closest ? e.target : (e.target && e.target.parentElement); if (t && t.closest && t.closest(NAV_ACTIVATORS)) e.preventDefault(); }, true);
|
||||
document.addEventListener('dblclick', (e) => { if (mode !== 'edit' || isUi(e.target)) return; const el = selectable(e.target); if (el) { if (!TEXT_TAGS.has(el.tagName.toLowerCase())) { const t = el.closest(core.TEXT_TAGS.join(',')); if (t && selectable(t)) { select(t); startEdit(t); } return; } select(el); startEdit(el); } }, true);
|
||||
document.addEventListener('submit', (e) => e.preventDefault(), true);
|
||||
document.addEventListener('mouseover', (e) => { if (mode !== 'edit' || isUi(e.target)) return; document.querySelectorAll('[' + MARK + 'hover]').forEach((n) => n.removeAttribute(MARK + 'hover')); const el = selectable(e.target); if (el && el !== selected) el.setAttribute(MARK + 'hover', ''); }, true);
|
||||
document.addEventListener('mouseout', (e) => { if (e.target && e.target.removeAttribute) e.target.removeAttribute(MARK + 'hover'); }, true);
|
||||
document.addEventListener('input', (e) => { if (editing && e.target === editing) changedSoon('text'); }, true);
|
||||
document.addEventListener('paste', (e) => { if (!editing) return; e.preventDefault(); const text = (e.clipboardData || window.clipboardData).getData('text/plain'); document.execCommand('insertText', false, text); }, true);
|
||||
document.addEventListener('blur', (e) => { if (editing && e.target === editing) endEdit(true); }, true);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const mod = e.ctrlKey || e.metaKey; const k = e.key.toLowerCase();
|
||||
if (mod && k === 's') { e.preventDefault(); if (editing) endEdit(true); send('hotkey', { key: 'save' }); return; }
|
||||
if (mod && k === 'z' && !editing) { e.preventDefault(); send('hotkey', { key: e.shiftKey ? 'redo' : 'undo' }); return; }
|
||||
if (mod && k === 'y' && !editing) { e.preventDefault(); send('hotkey', { key: 'redo' }); return; }
|
||||
if (e.key === 'Escape') { if (drag) { e.preventDefault(); endBlockDrag(false); } else if (editing) { e.preventDefault(); endEdit(true); } else if (selected) { e.preventDefault(); select(null); } return; }
|
||||
if (editing || mode !== 'edit' || !selected) return;
|
||||
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); moveSelected(e.key === 'ArrowUp' ? -1 : 1); return; }
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteSelected(); return; }
|
||||
if (mod && k === 'd') { e.preventDefault(); duplicateSelected(); return; }
|
||||
if (e.key === 'Enter') { e.preventDefault(); startEdit(selected); return; }
|
||||
}, true);
|
||||
|
||||
// ---------- 끌어 옮기기(기존 블록, 포인터 기반) ----------
|
||||
let drag = null; // { el, target, pointerId, move, up, cancel } — 끌기 중에만 있다
|
||||
function dropTargetAt(x, y, moving) {
|
||||
const hit = document.elementFromPoint(x, y); if (!hit) return null;
|
||||
let el = selectable(hit) ? blockOf(hit) : null;
|
||||
if (!el || el === document.body) { const ks = kids(document.body).filter((k) => k !== moving && !['script', 'style'].includes(k.tagName.toLowerCase())); if (!ks.length) return null; const idx = core.dropIndexFor(ks.map((k) => k.getBoundingClientRect()), y); return idx < ks.length ? { el: ks[idx], before: true } : { el: ks[ks.length - 1], before: false }; }
|
||||
if (moving && (el === moving || moving.contains(el))) return null;
|
||||
const r = el.getBoundingClientRect(); return { el, before: y < (r.top + r.bottom) / 2 };
|
||||
}
|
||||
function showDrop(t) { if (!t) { ui.drop.hidden = true; return; } const r = t.el.getBoundingClientRect(); ui.drop.style.left = (r.left + window.scrollX) + 'px'; ui.drop.style.width = r.width + 'px'; ui.drop.style.top = ((t.before ? r.top : r.bottom) + window.scrollY - 1) + 'px'; ui.drop.hidden = false; }
|
||||
/**
|
||||
* 끌기 끝(commit=true 면 놓기, false 면 취소). 어느 경로로 끝나도 같은 정리를 한다: 창 리스너 제거·포인터 캡처 해제·dragging 클래스 제거·놓기 표시 숨김.
|
||||
* pointercancel(브라우저가 끌기를 가로챔)·창 blur(포인터가 iframe 밖으로 나가 포커스를 잃음)·Escape 는 취소다 — 끌기 상태가 남아 다음 클릭을 옮기기로 오해하는 일이 없다.
|
||||
*/
|
||||
function endBlockDrag(commit) {
|
||||
const d = drag; drag = null; if (!d) return;
|
||||
window.removeEventListener('pointermove', d.move); window.removeEventListener('pointerup', d.up); window.removeEventListener('pointercancel', d.cancel); window.removeEventListener('blur', d.cancel);
|
||||
try { if (d.pointerId !== undefined && ui.grip.releasePointerCapture) ui.grip.releasePointerCapture(d.pointerId); } catch (_) {}
|
||||
d.el.classList.remove('wm-ve-dragging'); ui.drop.hidden = true;
|
||||
const t = commit ? d.target : null;
|
||||
if (t && t.el !== d.el) { if (t.before) t.el.before(d.el); else t.el.after(d.el); select(d.el); changed('drag'); } else renderOverlay();
|
||||
}
|
||||
function startPointerDrag(e) {
|
||||
if (!selected || mode !== 'edit' || drag) return; e.preventDefault(); endEdit(true);
|
||||
const moving = blockOf(selected) || selected;
|
||||
const d = { el: moving, target: null, pointerId: e.pointerId };
|
||||
d.move = (ev) => { if (drag !== d) return; d.target = dropTargetAt(ev.clientX, ev.clientY, moving); showDrop(d.target); };
|
||||
d.up = () => endBlockDrag(true); d.cancel = () => endBlockDrag(false);
|
||||
drag = d; moving.classList.add('wm-ve-dragging'); ui.tools.hidden = true;
|
||||
try { if (e.pointerId !== undefined && ui.grip.setPointerCapture) ui.grip.setPointerCapture(e.pointerId); } catch (_) {} // iframe 밖으로 나가도 pointermove/up 을 계속 받는다
|
||||
window.addEventListener('pointermove', d.move); window.addEventListener('pointerup', d.up); window.addEventListener('pointercancel', d.cancel); window.addEventListener('blur', d.cancel);
|
||||
}
|
||||
// ---------- 팔레트 포인터 끌기 다리(주 경로): 부모가 iframe 상대 좌표만 보내고, 위치 계산·삽입은 여기서 한다 ----------
|
||||
/** 부모가 보낸 끌기 payload 검증: 편집 모드 + 알려진 구성 요소 id + 이 뷰포트 안의 유한한 좌표. 아니면 null */
|
||||
function palettePoint(p) {
|
||||
if (mode !== 'edit' || !p || !core.isComponentId(p.component)) return null;
|
||||
const w = window.innerWidth || document.documentElement.clientWidth || 0; const h = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
return core.canvasPoint(p.x, p.y, w, h);
|
||||
}
|
||||
function paletteDragAt(p) { const pt = palettePoint(p); if (!pt) { if (ui.drop) ui.drop.hidden = true; return; } showDrop(dropTargetAt(pt.x, pt.y, null)); }
|
||||
function paletteDropAt(p) {
|
||||
if (ui.drop) ui.drop.hidden = true; const pt = palettePoint(p); if (!pt) return; // 잘못된 id·좌표·모드: 아무것도 넣지 않는다(표식도 없음)
|
||||
const t = dropTargetAt(pt.x, pt.y, null); insertComponent(p.component, t ? { path: pathOf(t.el), before: t.before } : 'end');
|
||||
}
|
||||
// ---------- 팔레트에서 놓기(HTML5 DnD, 대체 경로 — 부모 문서 → 이 iframe; 포인터 이벤트가 없는 환경용) ----------
|
||||
// 이 문서는 출처 'null'(sandbox) 이라 부모가 넣은 전용 MIME 'text/wm-ve-component' 는 브라우저가 넘겨주지 않는다. 부모는 표준 text/plain 에
|
||||
// 'wm-ve-component:<id>' 도 넣고, 여기서는 core.isComponentDrag(종류만 — dragover 때는 getData 불가) 로 끌기를 인식하고
|
||||
// drop 때 core.dragComponentId 로 전용 MIME 우선 → 정확히 맞는 text/plain 만 받는다. 임의 글·주소·HTML·모르는 id 는 아무것도 넣지 않는다.
|
||||
document.addEventListener('dragover', (e) => { if (mode !== 'edit' || !e.dataTransfer || !core.isComponentDrag(e.dataTransfer.types)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; showDrop(dropTargetAt(e.clientX, e.clientY, null)); });
|
||||
document.addEventListener('dragleave', (e) => { if (e.target === document.documentElement || !e.relatedTarget) ui.drop.hidden = true; });
|
||||
document.addEventListener('drop', (e) => {
|
||||
if (mode !== 'edit' || !e.dataTransfer || !core.isComponentDrag(e.dataTransfer.types)) return; e.preventDefault(); ui.drop.hidden = true;
|
||||
const id = core.dragComponentId(e.dataTransfer); if (!id) return; // 구성 요소가 아닌 끌기(임의 글 등): 기본 drop 도 막고 아무것도 넣지 않는다
|
||||
const t = dropTargetAt(e.clientX, e.clientY, null);
|
||||
try { insertComponent(id, t ? { path: pathOf(t.el), before: t.before } : 'end'); } catch (err) { send('error', { message: err.message }); }
|
||||
});
|
||||
|
||||
// ---------- 시작 ----------
|
||||
/** 시작: 편집기 UI 를 덧씌우고, 유일한 창 메시지인 ready 를 부모 출처로 보내며 port2 를 동봉한다(이후는 포트만) */
|
||||
function boot() { mountUi(); try { window.parent.postMessage(core.makeMessage(SESSION, 'ready', { title: document.title || '' }), PARENT_ORIGIN, [channel.port2]); } catch (_) {} }
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
|
||||
})();
|
||||
Reference in New Issue
Block a user