Files
CTStruct/ui/builder.html
gm 839a3222ec refactor(ui): 노드 중심 전면 개편 v0.4
- 노드 헤더: 구분자·이름·숨기기 토글·이동·삭제
- 값 섹션: 드롭다운으로 list/node/string/integer 등 타입별 값 추가, 복수 지원
  - list: 이름목록·카테고리·구분자·줄당 N명
  - node: 하위 노드 카드 인라인 중첩 렌더링
  - 스칼라: 단순 입력(textarea는 markdown/html/json/code)
- 노드 설정 섹션: 드롭다운으로 필요한 옵션만 추가, 미선택=기본값
  - header/footer/prefix/suffix/attributes
- 데이터 모델: sections[]+groups[]+children[] → nodes[]+values[] 통합
- 구버전 localStorage 자동 마이그레이션

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:54:11 +09:00

843 lines
43 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-cat-inp{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 6px;background:var(--surface);outline:none;min-width:0}
.val-cat-inp:focus{border-color:var(--border-focus)}
.val-cat-inp::placeholder{color:var(--muted)}
.val-body{padding:6px}
.val-names{width:100%;min-height:42px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:4px 6px;resize:vertical;background:var(--surface);line-height:1.55;outline:none}
.val-names:focus{border-color:var(--border-focus)}
.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)}
.val-data-ta{width:100%;min-height:60px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:4px 6px;resize:vertical;background:var(--surface);font-family:monospace;line-height:1.5;outline:none}
.val-data-ta:focus{border-color:var(--border-focus)}
.val-opts{display:flex;flex-direction:column;gap:4px;margin-top:5px;padding-top:5px;border-top:1px dashed var(--border)}
.opt-row{display:flex;align-items:center;gap:5px}
.opt-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);width:36px;flex-shrink:0}
.sep-chips{display:flex;gap:3px;flex-wrap:wrap}
.sep-chip{font-size:11px;padding:2px 6px;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)}
.sep-cust{width:48px;font-size:11px;border:1px solid var(--border);border-radius:4px;padding:2px 4px;background:var(--surface);text-align:center;outline:none}
.sep-cust:focus{border-color:var(--border-focus)}
.npl-inp{width:44px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 4px;text-align:center;background:var(--surface);outline:none}
.npl-inp:focus{border-color:var(--border-focus)}
.npl-inp:disabled{background:var(--surface-alt);color:var(--muted)}
.opt-hint{font-size:10px;color:var(--muted)}
/* ── 노드 설정 ────────────────────────────── */
.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}
.add-attr-btn{font-size:10px;padding:1px 7px;border-radius:4px;border:1px solid var(--accent);background:var(--accent-bg);color:var(--accent);cursor:pointer;font-weight:600}
.add-attr-btn:hover{background:var(--accent);color:#fff}
.attr-row{display:flex;align-items:center;gap:4px;margin-bottom:3px}
.attr-key{width:80px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 5px;background:var(--surface-alt);outline:none}
.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-key:focus,.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}
/* ── 모달 ─────────────────────────────────── */
.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>
<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 class="modal-actions">
<button class="mBtn cancel" onclick="closeImport()">취소</button>
<button class="mBtn ok" onclick="doImport()">불러오기</button>
</div>
</div>
</div>
<script>
// ════════════════════════════════════════════════════════
// 상수
// ════════════════════════════════════════════════════════
const VALUE_TYPES = [
{v:'list', lbl:'리스트 (List)'},
{v:'node', lbl:'노드 (Node)'},
{v:'string', lbl:'문자열 (String)'},
{v:'integer', lbl:'정수 (Integer)'},
{v:'float', lbl:'실수 (Float)'},
{v:'boolean', lbl:'불리언 (Boolean)'},
{v:'null', lbl:'Null'},
{v:'date', lbl:'날짜 (Date)'},
{v:'datetime', lbl:'날짜+시간 (Datetime)'},
{v:'time', lbl:'시간 (Time)'},
{v:'markdown', lbl:'Markdown'},
{v:'html', lbl:'HTML'},
{v:'json', lbl:'JSON'},
{v:'code', lbl:'코드 (Code)'},
{v:'custom', lbl:'사용자 정의 (Custom)'},
];
const SETTING_OPTIONS = [
{v:'header', lbl:'머리글 (header)'},
{v:'footer', lbl:'꼬리글 (footer)'},
{v:'prefix', lbl:'접두사 (prefix)'},
{v:'suffix', lbl:'접미사 (suffix)'},
{v:'attributes', lbl:'어트리뷰트'},
];
const SEP_PRESETS = [
{v:' ', lbl:'공백'},
{v:', ', lbl:', '},
{v:' / ', lbl:'/'},
{v:'\n', lbl:'↵'},
];
// ════════════════════════════════════════════════════════
// 팩토리
// ════════════════════════════════════════════════════════
function uid(){ return Math.random().toString(36).slice(2,9) }
function mkNode(mark='', content=''){
return { id:uid(), mark, content, hidden:false, values:[], settings:{} };
}
function mkListVal(names='', category=''){
return { id:uid(), type:'list', names, sep:' ', namesPerLine:0, category };
}
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); return; }
// try migrating old format
const old=localStorage.getItem('ctstruct-builder');
if(old){ const o=JSON.parse(old); S=migrateOld(o); save(); return; }
}catch(_){}
// default example
S=buildExample();
}
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 ?? '');
n.hidden = false;
// groups → list values
for(const g of s.groups??[]){
const lv = mkListVal(g.names??'', g.attributes?.category??'');
lv.sep = g.sep??' '; lv.namesPerLine = g.namesPerLine??0;
n.values.push(lv);
}
// children → node values
for(const c of s.children??[]) n.values.push(mkNodeVal(migSec(c, oldMarks)));
// scalar value
if(s.value!=null && s.data_type && s.data_type!=='unset'){
const sv=mkScalarVal(s.data_type); sv.data=String(s.value??''); n.values.push(sv);
}
// settings
if(s.header) n.settings.header=s.header;
if(s.footer) n.settings.footer=s.footer;
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');
if(filtAttrs.length) n.settings.attributes=Object.fromEntries(filtAttrs);
return n;
}
function buildExample(){
const n1=mkNode('🔹','1번 항목');
n1.values.push(mkListVal('홍길동 홍길순 홍길영 홍길준\n홍길호 홍길윤',''));
const n2=mkNode('🔹','2번 항목');
const c1=mkNode('🔸','2-1 항목'); c1.values.push(mkListVal('홍길찬 홍길만','건강'));
const c2=mkNode('🔸','2-2 항목'); c2.values.push(mkListVal('홍길주','직장'));
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;
let v;
if(type==='list') v=mkListVal();
else if(type==='node') v=mkNodeVal();
else v=mkScalarVal(type);
n.values.push(v); refresh();
if(type==='list') focusNew('.val-names');
}
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 addAttr(nid){
const n=nodeMap[nid]; if(!n||!n.settings.attributes) return;
const k=Object.keys(n.settings.attributes).includes('')? '_'+Date.now() : '';
n.settings.attributes[k]=''; refresh();
setTimeout(()=>{
const rows=document.querySelectorAll(`[data-nid="${nid}"] .attr-key`);
if(rows.length) rows[rows.length-1].focus();
},30);
}
function setAttrKey(nid, old, neo){
const n=nodeMap[nid]; if(!n||!n.settings.attributes||old===neo) return;
const v=n.settings.attributes[old]; delete n.settings.attributes[old];
n.settings.attributes[neo]=v; updatePreview(); save();
}
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;') }
// ════════════════════════════════════════════════════════
// 렌더링
// ════════════════════════════════════════════════════════
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>`
:`<span class="all-set">모두 설정됨</span>`}
</div>
${settingItems
?`<div class="body-sec-body">${settingItems}</div>`
:'<div class="empty-hint">기본값 사용</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>`;
}
if(val.type==='list'){
const sep=val.sep??' ', npl=val.namesPerLine??0, isNl=sep==='\n';
const chips=SEP_PRESETS.map(p=>`
<span class="sep-chip${sep===p.v?' on':''}"
onclick="valueMap['${val.id}'].val.sep=${JSON.stringify(p.v)};refresh()"
>${p.lbl}</span>`).join('');
const customVal=SEP_PRESETS.find(p=>p.v===sep)?'':esc(sep);
return `
<div class="val-item" data-vid="${val.id}">
<div class="val-head">
<span class="val-type-badge">LIST</span>
<input class="val-cat-inp" placeholder="카테고리 (선택)"
value="${esc(val.category??'')}"
oninput="valueMap['${val.id}'].val.category=this.value;updatePreview();save()">
${mvDel}
</div>
<div class="val-body">
<textarea class="val-names" placeholder="이름 입력 (공백·줄바꿈·쉼표로 구분)"
oninput="valueMap['${val.id}'].val.names=this.value;updatePreview();save()"
>${esc(val.names)}</textarea>
<div class="val-opts">
<div class="opt-row">
<span class="opt-lbl">구분</span>
<div class="sep-chips">${chips}</div>
<input class="sep-cust" placeholder="직접" value="${customVal}"
oninput="valueMap['${val.id}'].val.sep=this.value||' ';updatePreview();save()">
</div>
<div class="opt-row">
<span class="opt-lbl">줄당</span>
<input class="npl-inp" type="number" min="0" value="${npl}" ${isNl?'disabled':''}
oninput="valueMap['${val.id}'].val.namesPerLine=Math.max(0,+this.value||0);updatePreview();save()">
<span class="opt-hint">${isNl?'(줄바꿈 모드)':'개 (0=무제한)'}</span>
</div>
</div>
</div>
</div>`;
}
// 스칼라 타입
const typeLbl=VALUE_TYPES.find(t=>t.v===val.type)?.lbl??val.type.toUpperCase();
const isMulti=['markdown','html','json','code'].includes(val.type);
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">
${isMulti
?`<textarea class="val-data-ta" placeholder="${esc(typeLbl)} 입력"
oninput="valueMap['${val.id}'].val.data=this.value;updatePreview();save()"
>${esc(val.data??'')}</textarea>`
:`<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 renderSetting(nid, key, val){
if(key==='attributes'){
const attrs=val??{};
const rows=Object.entries(attrs).map(([k,v])=>`
<div class="attr-row">
<input class="attr-key" placeholder="키" value="${esc(k)}"
onblur="setAttrKey('${nid}','${esc(k)}',this.value)">
<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 data-nid="${nid}">
<div class="attrs-hdr">
<span class="attrs-lbl">어트리뷰트</span>
<button class="add-attr-btn" onclick="addAttr('${nid}')"> 추가</button>
<button class="iBtn" onclick="removeSetting('${nid}','attributes')" style="margin-left:4px">✕</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="${key}" value="${esc(String(val??''))}"
oninput="setSettingVal('${nid}','${key}',this.value)">
<button class="iBtn" onclick="removeSetting('${nid}','${key}')">✕</button>
</div>`;
}
// ════════════════════════════════════════════════════════
// 텍스트 변환
// ════════════════════════════════════════════════════════
function parseNames(raw){ return (raw??'').trim().split(/[\s,\n]+/).filter(Boolean) }
function toText(){
const lines=[];
const title=S.title.trim();
if(title){ lines.push(`${S.titleMark}${title}`); lines.push(''); }
function rNode(node){
lines.push(`${node.mark??''}${node.content||''}`);
let hadGroup=false;
for(const v of node.values){
if(v.type==='list'){
const ns=parseNames(v.names); if(!ns.length) continue;
const cat=(v.category??'').trim(), pre=cat?`(${cat}) `:'';
const sep=v.sep??' ', npl=v.namesPerLine??0;
if(sep==='\n') ns.forEach((n,i)=>lines.push((i===0?pre:'')+n));
else if(npl>0) for(let i=0;i<ns.length;i+=npl) lines.push((i===0?pre:'')+ns.slice(i,i+npl).join(sep));
else lines.push(pre+ns.join(sep));
lines.push(''); hadGroup=true;
} else if(v.type==='node'){
if(!hadGroup) lines.push('');
rNode(v.node); hadGroup=false;
} else {
if(v.data) lines.push(String(v.data));
}
}
}
for(const n of S.nodes) rNode(n);
return lines.join('\n').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,header:null,footer: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==='list'){
const ns=parseNames(v.names);
return buildNode({id:v.id,type:'LIST',
attributes:v.category?{category:v.category}:{},
children:ns.map(n=>buildNode({id:uid(),type:'LIST_ITEM',content:n}))});
}
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,
header:node.settings.header??null,
footer:node.settings.footer??null,
prefix:node.settings.prefix??null,
suffix:node.settings.suffix??null,
attributes:attrs,children
});
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);
});
}
// ════════════════════════════════════════════════════════
// 불러오기
// ════════════════════════════════════════════════════════
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 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 lv=mkListVal(pend.names.join(' '),pend.cat||'');
if(parent) parent.values.push(lv); 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>