251 lines
21 KiB
JavaScript
251 lines
21 KiB
JavaScript
/**
|
|||
|
|
* 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 };
|
||
|
|
}));
|