Files
CTStruct/ui/builder.html
2026-06-03 02:02:10 +09:00

883 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CTStruct 양식 빌더</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f0f2f5;--surface:#fff;--surface-alt:#f8f9fa;
--border:#e2e5ea;--border-focus:#4f6ef7;
--text:#1a1d23;--muted:#7c8594;
--accent:#4f6ef7;--accent-bg:#eef0ff;
--red:#ef4444;--red-bg:#fee2e2;
--green:#16a34a;--green-bg:#dcfce7;
--r:8px;--sh:0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.04);
--dk:#1e1e2e;--dk2:#181825;--dk3:#313244;--dk4:#45475a;
--dt:#cdd6f4;--dt2:#bac2de;--dt3:#6c7086;
}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;flex-direction:column;font-size:14px}
/* ── 헤더 ─────────────────────────────────── */
.app-header{background:var(--surface);border-bottom:1px solid var(--border);padding:10px 16px;display:flex;align-items:center;gap:10px;box-shadow:var(--sh);z-index:10;flex-shrink:0}
.app-header h1{font-size:15px;font-weight:600}
.badge{font-size:10px;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:99px;font-weight:600}
.hdr-spacer{flex:1}
.hdr-btn{display:flex;align-items:center;gap:5px;font-size:12px;padding:5px 12px;border-radius:6px;cursor:pointer;border:1px solid var(--border);background:var(--surface);color:var(--muted);transition:all .15s;font-weight:500}
.hdr-btn:hover{background:var(--surface-alt);color:var(--text);border-color:var(--border-focus)}
/* ── 레이아웃 ─────────────────────────────── */
.app-body{display:flex;flex:1;overflow:hidden}
.builder-panel{width:460px;min-width:340px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.panel-lbl{padding:10px 16px 8px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);border-bottom:1px solid var(--border);flex-shrink:0;background:var(--surface-alt)}
.builder-scroll{flex:1;overflow-y:auto;padding:12px}
.builder-scroll::-webkit-scrollbar{width:5px}
.builder-scroll::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
/* ── 문서 루트 ────────────────────────────── */
.root-row{display:flex;align-items:center;gap:8px;background:var(--surface-alt);border:1.5px solid var(--border);border-radius:var(--r);padding:7px 10px;margin-bottom:10px;transition:border-color .15s}
.root-row:focus-within{border-color:var(--border-focus);background:var(--surface)}
.root-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex-shrink:0}
.root-title{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent}
.root-title::placeholder{color:var(--muted);font-weight:400}
/* ── 공통 입력 ────────────────────────────── */
.mark-inp{width:34px;text-align:center;font-size:15px;border:1px solid var(--border);border-radius:5px;padding:2px 2px;background:var(--surface);flex-shrink:0;outline:none}
.mark-inp:focus{border-color:var(--border-focus)}
input,textarea,button,select{font-family:inherit}
/* ── 버튼 공통 ────────────────────────────── */
.iBtn{width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:4px;border:none;cursor:pointer;font-size:12px;background:transparent;color:var(--muted);transition:all .1s;flex-shrink:0}
.iBtn:hover{background:var(--red-bg);color:var(--red)}
.iBtn.mv:hover{background:var(--accent-bg);color:var(--accent)}
.iBtn.eye{font-size:13px}
.iBtn.eye.off{color:var(--border)}
.add-node-btn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;padding:8px;background:var(--accent-bg);color:var(--accent);border:1.5px dashed var(--accent);border-radius:var(--r);cursor:pointer;font-size:12px;font-weight:600;margin-bottom:10px;transition:all .15s}
.add-node-btn:hover{background:var(--accent);color:#fff;border-style:solid}
/* ── 노드 카드 ────────────────────────────── */
.node-list{display:flex;flex-direction:column;gap:8px}
.node-card{border:1.5px solid var(--border);border-radius:var(--r);background:var(--surface);box-shadow:var(--sh)}
.node-card.nested{border-color:var(--accent);border-left-width:3px}
.node-head{display:flex;align-items:center;gap:5px;padding:6px 8px;background:var(--surface-alt);border-bottom:1px solid var(--border);border-radius:calc(var(--r) - 1.5px) calc(var(--r) - 1.5px) 0 0}
.node-head.is-hidden{border-bottom:none;border-radius:calc(var(--r) - 1.5px)}
.name-inp{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent;min-width:0}
.name-inp::placeholder{color:var(--muted);font-weight:400}
.node-body{padding:8px;display:flex;flex-direction:column;gap:6px}
/* ── 섹션 (값 / 노드설정) ──────────────────── */
.body-sec{border:1px solid var(--border);border-radius:6px;overflow:hidden}
.body-sec-hdr{display:flex;align-items:center;gap:6px;padding:5px 8px;background:var(--surface-alt);border-bottom:1px solid var(--border)}
.body-sec-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex:1}
.add-sel{font-size:11px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:var(--text);cursor:pointer;outline:none;max-width:140px}
.add-sel:focus{border-color:var(--border-focus)}
.all-set{font-size:10px;color:var(--muted);font-style:italic}
.empty-hint{font-size:11px;color:var(--muted);font-style:italic;padding:7px 8px}
.body-sec-body{padding:7px 8px;display:flex;flex-direction:column;gap:6px}
/* ── 값 아이템 ────────────────────────────── */
.val-item{border:1px solid var(--border);border-radius:5px;background:var(--surface-alt);overflow:hidden}
.val-item.val-node{border:none;background:transparent;padding:0}
.val-head{display:flex;align-items:center;gap:5px;padding:4px 6px;background:var(--surface);border-bottom:1px solid var(--border)}
.val-type-badge{font-size:10px;font-weight:700;color:var(--muted);background:var(--surface-alt);border:1px solid var(--border);border-radius:3px;padding:1px 5px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0}
.val-body{padding:6px}
.val-data-inp{width:100%;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:4px 6px;background:var(--surface);outline:none}
.val-data-inp:focus{border-color:var(--border-focus)}
.sep-chip{font-size:11px;padding:2px 8px;border-radius:4px;border:1px solid var(--border);background:var(--surface);color:var(--muted);cursor:pointer;font-weight:500;transition:all .1s;white-space:nowrap}
.sep-chip:hover{border-color:var(--accent);color:var(--accent)}
.sep-chip.on{background:var(--accent);color:#fff;border-color:var(--accent)}
/* ── 구분자/이름줄바꿈 설정 (노드 설정 내부) ─ */
.struct-sep{display:flex;flex-direction:column;gap:4px;padding-bottom:6px;margin-bottom:6px;border-bottom:1px dashed var(--border)}
.sep-bar-row{display:flex;align-items:center;gap:5px}
.sep-bar-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);width:52px;flex-shrink:0}
/* ── 노드 설정 ────────────────────────────── */
.setting-row{display:flex;align-items:center;gap:5px;padding:3px 0}
.setting-lbl{font-size:11px;color:var(--muted);width:54px;flex-shrink:0}
.setting-inp{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt);outline:none}
.setting-inp:focus{border-color:var(--border-focus);background:#fff}
.attrs-hdr{display:flex;align-items:center;gap:5px;margin-bottom:4px}
.attrs-lbl{font-size:11px;color:var(--muted);flex:1}
.attr-row{display:flex;align-items:center;gap:4px;margin-bottom:3px}
.attr-key-lbl{font-size:11px;font-weight:600;color:var(--accent);background:var(--accent-bg);border:1px solid var(--accent);border-radius:4px;padding:2px 7px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;flex-shrink:0}
.attr-val{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 5px;background:var(--surface-alt);outline:none}
.attr-val:focus{border-color:var(--border-focus);background:#fff}
/* ── 미리보기 패널 ────────────────────────── */
.preview-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--dk)}
.preview-head{display:flex;align-items:center;gap:7px;padding:9px 14px;background:var(--dk2);border-bottom:1px solid var(--dk3);flex-shrink:0}
.tabs{display:flex;gap:2px}
.tab{font-size:12px;padding:4px 11px;border-radius:5px;cursor:pointer;color:var(--dt3);border:none;background:transparent;font-weight:500;transition:all .15s}
.tab.active{background:var(--dk3);color:var(--dt)}
.p-spacer{flex:1}
.copy-btn{display:flex;align-items:center;gap:5px;font-size:12px;padding:5px 11px;background:var(--dk3);color:var(--dt2);border:1px solid var(--dk4);border-radius:6px;cursor:pointer;transition:all .15s;font-weight:500}
.copy-btn:hover{background:var(--dk4)}
.copy-btn.ok{background:#1a3a2a;color:#a6e3a1;border-color:#a6e3a1}
.preview-body{flex:1;overflow-y:auto;padding:20px 22px}
.preview-body::-webkit-scrollbar{width:5px}
.preview-body::-webkit-scrollbar-track{background:var(--dk)}
.preview-body::-webkit-scrollbar-thumb{background:var(--dk4);border-radius:3px}
.preview-text{font-family:'Cascadia Code','Fira Code','Consolas',monospace;font-size:13px;line-height:1.75;color:var(--dt);white-space:pre-wrap;word-break:break-word}
.preview-text.dim{color:var(--dt3);font-style:italic}
.j-key{color:#89b4fa}.j-str{color:#a6e3a1}.j-num{color:#fab387}.j-bool{color:#f38ba8}.j-null{color:#f38ba8;font-style:italic}
/* ── 임포트 탭 ────────────────────────────── */
.import-tabs{display:flex;gap:0;margin-bottom:12px;border-bottom:2px solid var(--border)}
.itab{font-size:12px;padding:6px 16px;border:none;background:transparent;color:var(--muted);cursor:pointer;font-weight:500;border-bottom:2px solid transparent;margin-bottom:-2px;transition:all .15s}
.itab.active{color:var(--accent);border-bottom-color:var(--accent);font-weight:700}
.itab:hover:not(.active){color:var(--text)}
/* ── 모달 ─────────────────────────────────── */
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;z-index:200}
.overlay.open{display:flex}
.modal{background:var(--surface);border-radius:12px;padding:20px;width:500px;max-width:92vw;box-shadow:0 20px 60px rgba(0,0,0,.25)}
.modal h2{font-size:15px;font-weight:700;margin-bottom:6px}
.modal p{font-size:12px;color:var(--muted);margin-bottom:10px}
.modal-field{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.modal-field label{font-size:12px;color:var(--muted);width:80px;flex-shrink:0}
.modal-field input{font-size:13px;border:1.5px solid var(--border);border-radius:6px;padding:4px 8px;flex:1;outline:none}
.modal-field input:focus{border-color:var(--border-focus)}
.modal textarea{width:100%;height:175px;font-family:monospace;font-size:12px;border:1.5px solid var(--border);border-radius:6px;padding:9px;resize:vertical;line-height:1.6;outline:none}
.modal textarea:focus{border-color:var(--border-focus)}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
.mBtn{font-size:13px;padding:7px 16px;border-radius:6px;border:1px solid;cursor:pointer;font-weight:600}
.mBtn.ok{background:var(--accent);color:#fff;border-color:var(--accent)}
.mBtn.ok:hover{background:#3a5ce0}
.mBtn.cancel{background:var(--surface);color:var(--muted);border-color:var(--border)}
.mBtn.cancel:hover{background:var(--surface-alt)}
.hsep{height:1px;background:var(--border);margin:10px 0}
</style>
</head>
<body>
<header class="app-header">
<span style="font-size:18px">🏗️</span>
<h1>CTStruct 양식 빌더</h1>
<span class="badge">v0.4</span>
<div class="hdr-spacer"></div>
<button class="hdr-btn" onclick="openImport()">📥 불러오기</button>
<button class="hdr-btn" onclick="newDoc()">🗒 새 문서</button>
</header>
<div class="app-body">
<div class="builder-panel">
<div class="panel-lbl">✏️ 편집기</div>
<div class="builder-scroll">
<div class="root-row">
<span class="root-lbl">문서</span>
<input class="mark-inp" id="rootMark" title="제목 구분자"
oninput="S.titleMark=this.value;updatePreview();save()">
<input class="root-title" id="docTitle" placeholder="제목 없음"
oninput="S.title=this.value;updatePreview();save()">
</div>
<button class="add-node-btn" onclick="addTopNode()"> 노드 추가</button>
<div class="node-list" id="nodeList"></div>
</div>
</div>
<div class="preview-panel">
<div class="preview-head">
<div class="tabs">
<button class="tab active" id="tab-text" onclick="setTab('text')">텍스트</button>
<button class="tab" id="tab-json" onclick="setTab('json')">JSON slim</button>
<button class="tab" id="tab-json-full" onclick="setTab('json-full')">JSON full</button>
</div>
<div class="p-spacer"></div>
<button class="copy-btn" id="copyBtn" onclick="copyOutput()">📋 복사</button>
</div>
<div class="preview-body">
<pre class="preview-text" id="previewOut"></pre>
</div>
</div>
</div>
<div class="overlay" id="importModal">
<div class="modal">
<h2>📥 불러오기</h2>
<div class="import-tabs">
<button class="itab active" id="itab-text" onclick="switchImportTab('text')">텍스트</button>
<button class="itab" id="itab-json" onclick="switchImportTab('json')">JSON</button>
</div>
<!-- 텍스트 탭 -->
<div id="import-text-body">
<p>기존 양식 텍스트를 붙여넣으면 편집기로 불러옵니다.</p>
<div class="modal-field">
<label>제목 구분자</label>
<input id="importTitleMark" style="width:60px;flex:none" value="✅">
</div>
<div class="modal-field">
<label>섹션 구분자들</label>
<input id="importMarks" placeholder="🔹,🔸 (쉼표 구분, L1·L2 순서)">
</div>
<div class="hsep"></div>
<textarea id="importTxt" placeholder="✅예시 양식&#10;&#10;🔹1번 항목&#10;홍길동 홍길순&#10;&#10;🔹2번 항목&#10;🔸소항목&#10;(건강) 홍길찬"></textarea>
</div>
<!-- JSON 탭 -->
<div id="import-json-body" style="display:none">
<p>JSON slim 또는 JSON full 출력을 붙여넣으면 노드로 복원합니다.<br>
<span style="font-size:11px;color:var(--muted)">※ 구분자·줄바꿈 설정은 기본값으로 초기화됩니다.</span></p>
<textarea id="importJsonTxt" placeholder='{"type":"ROOT", "children":[...]}'></textarea>
</div>
<div class="modal-actions">
<button class="mBtn cancel" onclick="closeImport()">취소</button>
<button class="mBtn ok" onclick="doImportActive()">불러오기</button>
</div>
</div>
</div>
<script>
// ════════════════════════════════════════════════════════
// 상수
// ════════════════════════════════════════════════════════
const VALUE_TYPES = [
{v:'node', lbl:'노드 (Node)'},
{v:'string', lbl:'문자열 (String)'},
{v:'int', lbl:'정수 (Int)'},
{v:'bool', lbl:'불리언 (Bool)'},
{v:'date', lbl:'날짜 (Date)'},
];
const SETTING_OPTIONS = [
{v:'prefix', lbl:'앞 문자열 (prefix)'},
{v:'suffix', lbl:'뒤 문자열 (suffix)'},
{v:'attributes', lbl:'어트리뷰트'},
];
const ATTR_KEYS = [
{v:'lang', lbl:'lang — 언어'},
{v:'id', lbl:'id — 식별자'},
{v:'class', lbl:'class — 분류'},
{v:'href', lbl:'href — 링크'},
{v:'align', lbl:'align — 정렬'},
{v:'order', lbl:'order — 순서'},
{v:'required', lbl:'required — 필수'},
{v:'readonly', lbl:'readonly — 읽기전용'},
{v:'hidden', lbl:'hidden — 숨김'},
{v:'weight', lbl:'weight — 가중치'},
{v:'style', lbl:'style — 스타일'},
{v:'_custom', lbl:'직접 입력...'},
];
// ════════════════════════════════════════════════════════
// 팩토리
// ════════════════════════════════════════════════════════
function uid(){ return Math.random().toString(36).slice(2,9) }
function mkNode(mark='', content=''){
return { id:uid(), mark, content, hidden:false, values:[], settings:{},
nameNewline:true };
}
function mkNodeVal(childNode){
return { id:uid(), type:'node', node: childNode ?? mkNode() };
}
function mkScalarVal(type){
return { id:uid(), type, data:'' };
}
// ════════════════════════════════════════════════════════
// 상태
// ════════════════════════════════════════════════════════
let S = { titleMark:'✅', title:'예시 양식', nodes:[] };
// ID 맵 (렌더 전 재구성)
let nodeMap = {}; // nodeId → node
let valueMap = {}; // valueId → {val, nodeId}
let valNodeMap = {};// childNodeId → valueId (node-type value 역참조)
function rebuildMaps(){
nodeMap={}; valueMap={}; valNodeMap={};
function idx(n){
nodeMap[n.id]=n;
for(const v of n.values){
valueMap[v.id]={val:v, nodeId:n.id};
if(v.type==='node'){ valNodeMap[v.node.id]=v.id; idx(v.node); }
}
}
for(const n of S.nodes) idx(n);
}
let currentTab = 'text';
// ── 영속성 ─────────────────────────────────────────────
function save(){ try{ localStorage.setItem('ctstruct-v4', JSON.stringify(S)) }catch(_){} }
function load(){
try{
const raw=localStorage.getItem('ctstruct-v4');
if(raw){ S=JSON.parse(raw); normalizeState(S); return; }
const old=localStorage.getItem('ctstruct-builder');
if(old){ const o=JSON.parse(old); S=migrateOld(o); save(); return; }
}catch(_){}
S=buildExample();
}
const VALID_TYPES=new Set(['node','string','int','bool','date']);
function normalizeNode(n){
if(n.nameNewline===undefined) n.nameNewline=true;
// list → string, 지원하지 않는 타입 → string
n.values=(n.values||[]).map(v=>{
if(v.type==='list'){
const sv=mkScalarVal('string'); sv.data=(v.names??'').trim(); return sv;
}
if(v.type==='node'){ normalizeNode(v.node); return v; }
if(v.type==='integer') v.type='int';
if(v.type==='boolean') v.type='bool';
if(!VALID_TYPES.has(v.type)) v.type='string';
return v;
});
}
function normalizeState(s){
for(const n of s.nodes||[]) normalizeNode(n);
}
function migrateOld(o){
const titleMark = o.titleMark ?? o.cfg?.titleMark ?? '✅';
const oldMarks = o.cfg?.marks ?? [];
const sections = o.sections ?? o.nodes ?? [];
return {
titleMark,
title: o.title ?? '',
nodes: sections.map(s => migSec(s, oldMarks))
};
}
function migSec(s, oldMarks){
const mark = s.mark ?? oldMarks[(s.lv??1)-1] ?? '';
const n = mkNode(mark, s.content ?? s.name ?? '');
// groups → string values (names 합치기)
for(const g of s.groups??[]){
const names=(g.names??'').trim(); if(!names) continue;
const sv=mkScalarVal('string'); sv.data=names; n.values.push(sv);
}
for(const c of s.children??[]) n.values.push(mkNodeVal(migSec(c, oldMarks)));
if(s.value!=null && s.data_type && s.data_type!=='unset'){
const sv=mkScalarVal('string'); sv.data=String(s.value??''); n.values.push(sv);
}
if(s.prefix) n.settings.prefix=s.prefix;
if(s.suffix) n.settings.suffix=s.suffix;
const filtAttrs=Object.entries(s.attributes??{}).filter(([k])=>k!=='level'&&k!=='category'&&k!=='mark');
if(filtAttrs.length) n.settings.attributes=Object.fromEntries(filtAttrs);
return n;
}
function buildExample(){
const n1=mkNode('🔹','1번 항목');
const sv1=mkScalarVal('string'); sv1.data='홍길동 홍길순 홍길영 홍길준'; n1.values.push(sv1);
const n2=mkNode('🔹','2번 항목');
const c1=mkNode('🔸','2-1 항목'); const sv2=mkScalarVal('string'); sv2.data='홍길찬 홍길만'; c1.values.push(sv2);
const c2=mkNode('🔸','2-2 항목'); const sv3=mkScalarVal('string'); sv3.data='홍길주'; c2.values.push(sv3);
n2.values.push(mkNodeVal(c1)); n2.values.push(mkNodeVal(c2));
return {titleMark:'✅', title:'예시 양식', nodes:[n1,n2]};
}
// ════════════════════════════════════════════════════════
// CRUD — 노드
// ════════════════════════════════════════════════════════
function addTopNode(){
S.nodes.push(mkNode()); refresh(); focusNew('.name-inp');
}
function deleteNode(nid){
const vid=valNodeMap[nid];
if(vid){ removeValue(vid); return; }
S.nodes=S.nodes.filter(n=>n.id!==nid);
refresh();
}
function moveNode(nid,dir){
const vid=valNodeMap[nid];
if(vid){ moveValue(vid,dir); return; }
const i=S.nodes.findIndex(n=>n.id===nid), j=i+dir;
if(j<0||j>=S.nodes.length) return;
[S.nodes[i],S.nodes[j]]=[S.nodes[j],S.nodes[i]];
refresh();
}
function toggleHidden(nid){
const n=nodeMap[nid]; if(!n) return;
n.hidden=!n.hidden; refresh();
}
// ════════════════════════════════════════════════════════
// CRUD — 값
// ════════════════════════════════════════════════════════
function addValue(nid, type){
if(!type) return;
const n=nodeMap[nid]; if(!n) return;
const v = type==='node' ? mkNodeVal() : mkScalarVal(type);
n.values.push(v); refresh();
}
function removeValue(vid){
const e=valueMap[vid]; if(!e) return;
const n=nodeMap[e.nodeId]; if(!n) return;
n.values=n.values.filter(v=>v.id!==vid); refresh();
}
function moveValue(vid,dir){
const e=valueMap[vid]; if(!e) return;
const n=nodeMap[e.nodeId]; if(!n) return;
const i=n.values.findIndex(v=>v.id===vid), j=i+dir;
if(j<0||j>=n.values.length) return;
[n.values[i],n.values[j]]=[n.values[j],n.values[i]];
refresh();
}
// ════════════════════════════════════════════════════════
// CRUD — 설정
// ════════════════════════════════════════════════════════
function addSetting(nid, key){
if(!key) return;
const n=nodeMap[nid]; if(!n||key in n.settings) return;
n.settings[key]=key==='attributes'?{}:''; refresh();
}
function removeSetting(nid, key){
const n=nodeMap[nid]; if(!n) return;
delete n.settings[key]; refresh();
}
function setSettingVal(nid, key, val){
const n=nodeMap[nid]; if(!n) return;
n.settings[key]=val||null; updatePreview(); save();
}
// ── 어트리뷰트 ─────────────────────────────────────────
function handleAddAttr(nid, key){
if(!key) return;
if(key==='_custom'){
const k=(prompt('어트리뷰트 키 이름 입력:')??'').trim();
if(k) addAttr(nid, k);
} else {
addAttr(nid, key);
}
}
function addAttr(nid, key){
const n=nodeMap[nid]; if(!n||!n.settings.attributes||key in n.settings.attributes) return;
n.settings.attributes[key]=''; refresh();
setTimeout(()=>{
const inp=document.querySelector(`[data-attrnode="${nid}"][data-attrkey] .attr-val`);
// focus the last added attr-val
const all=document.querySelectorAll(`[data-attrnode="${nid}"] .attr-val`);
if(all.length) all[all.length-1].focus();
},40);
}
function setAttrVal(nid, key, val){
const n=nodeMap[nid]; if(!n||!n.settings.attributes) return;
n.settings.attributes[key]=val; updatePreview(); save();
}
function removeAttr(nid, key){
const n=nodeMap[nid]; if(!n||!n.settings.attributes) return;
delete n.settings.attributes[key]; refresh();
}
// ════════════════════════════════════════════════════════
// 헬퍼
// ════════════════════════════════════════════════════════
function refresh(){ rebuildMaps(); renderPanel(); updatePreview(); save(); }
function focusNew(sel){ setTimeout(()=>{ const els=document.querySelectorAll(sel); if(els.length) els[els.length-1].focus(); },40); }
function esc(s){ return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') }
// 입력값의 \n \t → 실제 문자 / 실제 문자 → 표시용 이스케이프
function unesc(s){ return s.replace(/\\n/g,'\n').replace(/\\t/g,'\t').replace(/\\r/g,'\r') }
function reesc(s){ return s.replace(/\r/g,'\\r').replace(/\n/g,'\\n').replace(/\t/g,'\\t') }
// ════════════════════════════════════════════════════════
// 렌더링
// ════════════════════════════════════════════════════════
function renderPanel(){
rebuildMaps();
document.getElementById('rootMark').value=S.titleMark;
document.getElementById('docTitle').value=S.title;
document.getElementById('nodeList').innerHTML=S.nodes.map(n=>renderNode(n,false)).join('');
}
function renderNode(node, nested){
const valueItems=node.values.map(v=>renderValue(node.id,v)).join('');
const typeOpts=VALUE_TYPES.map(t=>`<option value="${t.v}">${t.lbl}</option>`).join('');
const availSet=SETTING_OPTIONS.filter(o=>!(o.v in node.settings));
const setOpts=availSet.map(o=>`<option value="${o.v}">${o.lbl}</option>`).join('');
const settingItems=Object.entries(node.settings).map(([k,v])=>renderSetting(node.id,k,v)).join('');
return `
<div class="node-card${nested?' nested':''}" data-nid="${node.id}">
<div class="node-head${node.hidden?' is-hidden':''}">
<input class="mark-inp" title="구분자" value="${esc(node.mark??'')}"
oninput="nodeMap['${node.id}'].mark=this.value;updatePreview();save()">
<input class="name-inp" placeholder="노드 이름" value="${esc(node.content??'')}"
oninput="nodeMap['${node.id}'].content=this.value;updatePreview();save()">
<button class="iBtn eye${node.hidden?' off':''}" title="숨기기 토글"
onclick="toggleHidden('${node.id}')">👁</button>
<button class="iBtn mv" onclick="moveNode('${node.id}',-1)" title="위로">↑</button>
<button class="iBtn mv" onclick="moveNode('${node.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="deleteNode('${node.id}')" title="삭제">✕</button>
</div>
${!node.hidden?`
<div class="node-body">
<!-- 값 -->
<div class="body-sec">
<div class="body-sec-hdr">
<span class="body-sec-title">값</span>
<select class="add-sel"
onchange="addValue('${node.id}',this.value);this.selectedIndex=0">
<option value=""> 추가</option>
${typeOpts}
</select>
</div>
${valueItems
?`<div class="body-sec-body">${valueItems}</div>`
:'<div class="empty-hint">값 없음 — 위에서 추가</div>'}
</div>
<!-- 노드 설정 -->
<div class="body-sec">
<div class="body-sec-hdr">
<span class="body-sec-title">노드 설정</span>
${availSet.length
?`<select class="add-sel"
onchange="addSetting('${node.id}',this.value);this.selectedIndex=0">
<option value=""> 옵션 추가</option>
${setOpts}
</select>`:''}
</div>
<div class="body-sec-body">
${renderStructSettings(node)}
${settingItems}
</div>
</div>
</div>`:''}
</div>`;
}
function renderValue(nid, val){
const mvDel=`
<button class="iBtn mv" onclick="moveValue('${val.id}',-1)" title="위로">↑</button>
<button class="iBtn mv" onclick="moveValue('${val.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="removeValue('${val.id}')" title="삭제">✕</button>`;
if(val.type==='node'){
return `<div class="val-item val-node" data-vid="${val.id}">${renderNode(val.node,true)}</div>`;
}
const typeLbl=VALUE_TYPES.find(t=>t.v===val.type)?.lbl??val.type.toUpperCase();
return `
<div class="val-item" data-vid="${val.id}">
<div class="val-head">
<span class="val-type-badge">${val.type.toUpperCase()}</span>
<span style="flex:1"></span>
${mvDel}
</div>
<div class="val-body">
<input class="val-data-inp" placeholder="${esc(typeLbl)} 입력"
value="${esc(val.data??'')}"
oninput="valueMap['${val.id}'].val.data=this.value;updatePreview();save()">
</div>
</div>`;
}
function setNameNL(nid, val){
const n=nodeMap[nid]; if(!n) return;
n.nameNewline=val; refresh();
}
function renderStructSettings(node){
const nid=node.id, nl=node.nameNewline??true;
return `
<div class="struct-sep">
<div class="sep-bar-row">
<span class="sep-bar-lbl">이름 뒤</span>
<span class="sep-chip${nl?' on':''}" onclick="setNameNL('${nid}',true)">줄바꿈</span>
<span class="sep-chip${!nl?' on':''}" onclick="setNameNL('${nid}',false)">없음</span>
</div>
</div>`;
}
function renderSetting(nid, key, val){
if(key==='attributes'){
const attrs=val??{};
const usedKeys=Object.keys(attrs);
const availKeys=ATTR_KEYS.filter(o=>o.v==='_custom'||!usedKeys.includes(o.v));
const attrKeyOpts=availKeys.map(o=>`<option value="${esc(o.v)}">${esc(o.lbl)}</option>`).join('');
const rows=Object.entries(attrs).map(([k,v])=>{
const def=ATTR_KEYS.find(o=>o.v===k);
const lbl=def?def.lbl:k;
return `
<div class="attr-row" data-attrnode="${nid}" data-attrkey="${esc(k)}">
<span class="attr-key-lbl" title="${esc(k)}">${esc(lbl)}</span>
<input class="attr-val" placeholder="값" value="${esc(String(v??''))}"
oninput="setAttrVal('${nid}','${esc(k)}',this.value)">
<button class="iBtn" onclick="removeAttr('${nid}','${esc(k)}')">✕</button>
</div>`;
}).join('');
return `
<div>
<div class="attrs-hdr">
<span class="attrs-lbl">어트리뷰트</span>
<select class="add-sel" onchange="handleAddAttr('${nid}',this.value);this.selectedIndex=0">
<option value=""> 추가</option>
${attrKeyOpts}
</select>
<button class="iBtn" onclick="removeSetting('${nid}','attributes')">✕</button>
</div>
${rows||'<div class="empty-hint" style="padding:2px 0">없음</div>'}
</div>`;
}
return `
<div class="setting-row">
<span class="setting-lbl">${key}</span>
<input class="setting-inp" placeholder="\\n \\t 사용 가능" value="${esc(reesc(String(val??'')))}"
oninput="setSettingVal('${nid}','${key}',unesc(this.value))">
<button class="iBtn" onclick="removeSetting('${nid}','${key}')">✕</button>
</div>`;
}
// ════════════════════════════════════════════════════════
// 텍스트 변환
// ════════════════════════════════════════════════════════
function parseNames(raw){ return (raw??'').trim().split(/[\s,\n]+/).filter(Boolean) }
function toText(){
function rNode(node){
const pre=node.settings.prefix??'';
const suf=node.settings.suffix??'';
const nl=(node.nameNewline??true)?'\n':'';
let t=pre+`${node.mark??''}${node.content||''}`;
let first=true;
for(const v of node.values){
if(v.type==='node'){
t+=rNode(v.node); first=false;
} else if(v.data!==undefined&&v.data!==null&&String(v.data).length){
t+=(first?nl:'\n')+String(v.data); first=false;
}
}
t+=suf;
return t;
}
const title=S.title.trim();
let out=title?`${S.titleMark}${title}\n`:'';
out+=S.nodes.map(rNode).join('');
return out.replace(/\n{3,}/g,'\n\n').trimEnd()+'\n';
}
// ════════════════════════════════════════════════════════
// JSON 변환
// ════════════════════════════════════════════════════════
function stripNulls(obj){
if(Array.isArray(obj)) return obj.map(stripNulls);
if(obj&&typeof obj==='object'){
const r={};
for(const [k,v] of Object.entries(obj)){
if(v===null) continue;
if(k==='data_type'&&v==='unset') continue;
if(k==='attributes'&&typeof v==='object'&&!Object.keys(v).length) continue;
r[k]=stripNulls(v);
}
return r;
}
return obj;
}
function buildNode(o){
return {content:null,prefix:null,suffix:null,
attributes:{},data_type:'unset',value:null,children:[],...o};
}
function toJSON(slim=false){
let depth=0;
function serNode(node){
depth++;
const children=node.values.map(v=>{
if(v.type==='node') return serNode(v.node);
return buildNode({id:v.id,type:'VALUE',data_type:v.type,value:v.data??null});
});
const attrs={mark:node.mark,level:depth,...(node.settings.attributes??{})};
const r=buildNode({
id:node.id,type:'SECTION',content:node.content,
prefix:node.settings.prefix??null,
suffix:node.settings.suffix??null,
attributes:attrs,children,
nameNewline:node.nameNewline??true,
});
depth--;
return r;
}
const root=buildNode({
id:'root',type:'ROOT',
attributes:S.title.trim()?{title:S.title.trim(),mark:S.titleMark}:{mark:S.titleMark},
children:S.nodes.map(serNode)
});
return JSON.stringify(slim?stripNulls(root):root,null,2);
}
// ════════════════════════════════════════════════════════
// JSON 구문 강조
// ════════════════════════════════════════════════════════
function highlight(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/("(?:[^"\\]|\\.)*")(\s*:)/g,'<span class="j-key">$1</span>$2')
.replace(/:\s*("(?:[^"\\]|\\.)*")/g,': <span class="j-str">$1</span>')
.replace(/:\s*(-?\d+\.?\d*)/g,': <span class="j-num">$1</span>')
.replace(/:\s*(true|false)/g,': <span class="j-bool">$1</span>')
.replace(/:\s*(null)/g,': <span class="j-null">$1</span>');
}
// ════════════════════════════════════════════════════════
// 미리보기
// ════════════════════════════════════════════════════════
function setTab(t){
currentTab=t;
document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));
document.getElementById('tab-'+t).classList.add('active');
updatePreview();
}
function updatePreview(){
const el=document.getElementById('previewOut');
if(currentTab==='text'){
const c=toText();
if(!c.trim()){ el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; }
el.className='preview-text'; el.textContent=c;
} else {
el.className='preview-text'; el.innerHTML=highlight(toJSON(currentTab==='json'));
}
}
// ════════════════════════════════════════════════════════
// 복사
// ════════════════════════════════════════════════════════
function copyOutput(){
const c=currentTab==='text'?toText():toJSON(currentTab==='json');
navigator.clipboard.writeText(c).then(()=>{
const btn=document.getElementById('copyBtn');
btn.textContent='✅ 복사됨'; btn.classList.add('ok');
setTimeout(()=>{ btn.innerHTML='📋 복사'; btn.classList.remove('ok'); },2000);
});
}
// ════════════════════════════════════════════════════════
// 불러오기
// ════════════════════════════════════════════════════════
let importTab='text';
function switchImportTab(tab){
importTab=tab;
document.getElementById('import-text-body').style.display=tab==='text'?'':'none';
document.getElementById('import-json-body').style.display=tab==='json'?'':'none';
document.querySelectorAll('.itab').forEach(el=>el.classList.remove('active'));
document.getElementById('itab-'+tab).classList.add('active');
}
function openImport(){
document.getElementById('importTitleMark').value=S.titleMark;
const marks=[]; function col(nodes){ for(const n of nodes){ if(n.mark&&!marks.includes(n.mark)) marks.push(n.mark); for(const v of n.values) if(v.type==='node') col([v.node]); } }
col(S.nodes);
document.getElementById('importMarks').value=marks.join(',')||'🔹,🔸';
document.getElementById('importModal').classList.add('open');
}
function closeImport(){ document.getElementById('importModal').classList.remove('open') }
function doImportActive(){
if(importTab==='json') doImportJson(); else doImport();
}
// ── JSON 파서 ──────────────────────────────────────────
function doImportJson(){
const raw=document.getElementById('importJsonTxt').value.trim();
if(!raw) return;
let obj;
try{ obj=JSON.parse(raw); }
catch(e){ alert('JSON 파싱 오류:\n'+e.message); return; }
if(obj.type!=='ROOT'){ alert('최상위 타입이 ROOT가 아닙니다.'); return; }
S.titleMark = obj.attributes?.mark ?? '✅';
S.title = obj.attributes?.title ?? '';
S.nodes = (obj.children??[]).filter(c=>c.type==='SECTION').map(parseJsonSection);
refresh(); closeImport();
document.getElementById('importJsonTxt').value='';
}
function parseJsonSection(sec){
const mark=sec.attributes?.mark??'';
const n=mkNode(mark, sec.content??'');
if(sec.nameNewline!==undefined) n.nameNewline=sec.nameNewline;
if(sec.prefix) n.settings.prefix=sec.prefix;
if(sec.suffix) n.settings.suffix=sec.suffix;
const filtAttrs=Object.entries(sec.attributes??{}).filter(([k])=>k!=='mark'&&k!=='level');
if(filtAttrs.length) n.settings.attributes=Object.fromEntries(filtAttrs);
for(const child of sec.children??[]){
if(child.type==='SECTION'){
n.values.push(mkNodeVal(parseJsonSection(child)));
} else if(child.type==='VALUE'){
const sv=mkScalarVal(child.data_type??'string');
sv.data=String(child.value??'');
n.values.push(sv);
} else if(child.type==='LIST'){
// 구버전 호환: LIST → string value
const names=(child.children??[]).filter(c=>c.type==='LIST_ITEM').map(c=>c.content??'').join(' ');
const sv=mkScalarVal('string'); sv.data=names; n.values.push(sv);
}
}
return n;
}
function doImport(){
const raw=document.getElementById('importTxt').value.trim(); if(!raw) return;
const titleMark=document.getElementById('importTitleMark').value||'✅';
const marks=document.getElementById('importMarks').value.split(',').map(m=>m.trim()).filter(Boolean);
const sorted=marks.map((m,i)=>({m,lv:i+1})).sort((a,b)=>b.m.length-a.m.length);
const reCat=/^\(([^)]+)\)\s*(.*)/;
let title=''; const nodes=[]; const stack=[]; let pend=null;
function flush(){
if(!pend||!pend.names.length){ pend=null; return; }
const parent=pend.parentId?findNodeInList(nodes,pend.parentId):null;
const sv=mkScalarVal('string'); sv.data=pend.names.join(' ');
if(parent) parent.values.push(sv); pend=null;
}
function findNodeInList(list,id){ for(const n of list){ if(n.id===id) return n; for(const v of n.values) if(v.type==='node'){ const f=findNodeInList([v.node],id); if(f) return f; } } return null; }
function topId(){ return stack.length?stack[stack.length-1].id:null }
for(const rawLine of raw.split('\n')){
const line=rawLine.trim(); if(!line){ flush(); continue; }
if(line.startsWith(titleMark)){ flush(); title=line.slice(titleMark.length).trim(); continue; }
let matched=false;
for(const {m,lv} of sorted){
if(line.startsWith(m)){
flush();
const node=mkNode(m, line.slice(m.length).trim());
while(stack.length){
const top=findNodeInList(nodes,stack[stack.length-1].id);
if(top&&stack[stack.length-1].lv>=lv) stack.pop(); else break;
}
if(!stack.length) nodes.push(node);
else{ const p=findNodeInList(nodes,stack[stack.length-1].id); if(p) p.values.push(mkNodeVal(node)); }
stack.push({id:node.id,lv}); matched=true; break;
}
}
if(matched) continue;
const cm=reCat.exec(line);
if(cm){ flush(); pend={parentId:topId(),cat:cm[1].trim(),names:[]}; const rest=cm[2].trim(); if(rest) pend.names.push(...rest.split(/[\s,]+/).filter(Boolean)); continue; }
if(!pend) pend={parentId:topId(),cat:'',names:[]};
pend.names.push(...line.split(/[\s,]+/).filter(Boolean));
}
flush();
S.titleMark=titleMark; S.title=title; S.nodes=nodes;
refresh(); closeImport(); document.getElementById('importTxt').value='';
}
// ════════════════════════════════════════════════════════
// 새 문서
// ════════════════════════════════════════════════════════
function newDoc(){
if(!confirm('현재 내용을 모두 지우고 새 문서를 시작할까요?')) return;
S={titleMark:'✅',title:'',nodes:[]}; refresh();
}
// ════════════════════════════════════════════════════════
// 초기화
// ════════════════════════════════════════════════════════
load();
rebuildMaps();
renderPanel();
updatePreview();
document.getElementById('importModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeImport(); });
document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeImport(); });
</script>
</body>
</html>