/**
* 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; HH; 세미콜론 생략 포함)는 모두 푼다.
* 여기 없는 이름 엔티티는 풀지 않고 그대로 두며, 그것이 스킴 자리에 남아 있으면 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: ' 여기에 내용을 입력하세요.새 섹션
여기에 내용을 입력하세요.
' }, { id: 'button', label: '버튼(링크)', hint: '눌러서 이동하는 링크 버튼', html: '버튼' }, { id: 'image', label: '이미지', hint: '이미지(주소·대체 글은 속성에서)', html: '