Files
AriWeb-Suite/modules/webmaker/public/wm-ve-canvas.js
T

296 lines
28 KiB
JavaScript
Raw Normal View History

/**
* 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();
})();