Files
CTStruct/ui/builder.html
gm 33a1296e8b refactor(ui): per-node delimiter, node settings panel consolidation
각 노드가 독립적인 구분자를 가지도록 전면 개편. 전역 구분자 설정 카드 제거.
노드 카드 구조: 구분자·제목·노드설정·하위노드. 이름 목록(LIST 그룹)을
노드설정 패널 안으로 통합. 불러오기 모달에 구분자 직접 입력 추가.

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

864 lines
45 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,.05);
--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-label{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:14px}
.builder-scroll::-webkit-scrollbar{width:5px}
.builder-scroll::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
/* ── 문서 루트 노드 ───────────────────────── */
.root-node{display:flex;align-items:center;gap:8px;background:var(--surface-alt);border:1.5px solid var(--border);border-radius:var(--r);padding:7px 11px;margin-bottom:12px;transition:border-color .15s}
.root-node: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-inp{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent}
.root-title-inp::placeholder{color:var(--muted);font-weight:400}
/* ── 구분자 입력 (공통) ───────────────────── */
.node-mark-inp{width:36px;text-align:center;font-size:15px;border:1px solid var(--border);border-radius:5px;padding:2px 3px;background:var(--surface);flex-shrink:0;outline:none}
.node-mark-inp:focus{border-color:var(--border-focus)}
/* ── 버튼 ─────────────────────────────────── */
.btn-add-sec{display:flex;align-items:center;justify-content:center;gap:7px;width:100%;padding:9px;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:12px;transition:all .15s}
.btn-add-sec:hover{background:var(--accent);color:#fff;border-style:solid}
.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.up:hover,.iBtn.dn:hover{background:var(--accent-bg);color:var(--accent)}
.sBtn{font-size:11px;padding:3px 9px;border-radius:4px;border:1px solid;cursor:pointer;font-weight:600;transition:all .12s}
.sBtn.blue{background:var(--accent-bg);color:var(--accent);border-color:var(--accent)}
.sBtn.blue:hover{background:var(--accent);color:#fff}
/* ── 섹션 카드 ────────────────────────────── */
.sec-list{display:flex;flex-direction:column;gap:9px}
.sec-card{border:1.5px solid var(--border);border-radius:var(--r);background:var(--surface);box-shadow:var(--sh)}
.sec-head{display:flex;align-items:center;gap:6px;padding:7px 9px;background:var(--surface-alt);border-bottom:1px solid var(--border);border-radius:calc(var(--r) - 1.5px) calc(var(--r) - 1.5px) 0 0}
.sec-name-input{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent;min-width:0}
.sec-name-input::placeholder{color:var(--muted);font-weight:400}
.sec-body{padding:8px 9px 9px;display:flex;flex-direction:column;gap:8px}
/* ── 노드 설정 패널 ───────────────────────── */
.node-settings{}
.ns-toggle{display:flex;align-items:center;gap:5px;padding:5px 9px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);cursor:pointer;background:var(--surface-alt);border:1px solid var(--border);border-radius:5px;transition:all .12s;user-select:none}
.ns-toggle:hover{color:var(--accent);border-color:var(--accent);background:var(--accent-bg)}
.ns-toggle.open{border-radius:5px 5px 0 0;border-bottom-color:transparent;color:var(--accent);background:var(--accent-bg);border-color:var(--accent)}
.ns-arrow{font-size:8px;transition:transform .15s}
.ns-dot{width:6px;height:6px;border-radius:50%;background:var(--accent);flex-shrink:0}
.ns-body{border:1px solid var(--accent);border-top:none;border-radius:0 0 5px 5px;background:var(--surface);padding:10px;display:flex;flex-direction:column;gap:10px}
.ns-section{}
.ns-section-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:6px;display:flex;align-items:center;justify-content:space-between}
.ns-deco-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px}
.ns-field{display:flex;flex-direction:column;gap:2px}
.ns-field-lbl{font-size:10px;color:var(--muted)}
.ns-field-inp{font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt)}
.ns-field-inp:focus{outline:none;border-color:var(--border-focus);background:#fff}
.ns-attr-row{display:flex;gap:4px;margin-bottom:4px;align-items:center}
.ns-attr-key{width:90px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt)}
.ns-attr-val{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt)}
.ns-attr-key:focus,.ns-attr-val:focus{outline:none;border-color:var(--border-focus);background:#fff}
.ns-empty{font-size:11px;color:var(--muted);font-style:italic;padding:2px 0}
.ns-value-row{display:flex;gap:6px;align-items:center}
.ns-dtype{flex:0 0 auto;min-width:130px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt)}
.ns-dtype:focus{outline:none;border-color:var(--border-focus)}
.ns-value-inp{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt)}
.ns-value-inp:focus{outline:none;border-color:var(--border-focus);background:#fff}
.ns-add-btn{font-size:10px;padding:1px 7px}
/* ── 이름 목록 그룹 ───────────────────────── */
.grp{border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:7px;background:var(--surface-alt)}
.grp:last-child{margin-bottom:0}
.grp-head{display:flex;align-items:center;gap:6px;margin-bottom:6px}
.grp-type-badge{font-size:10px;font-weight:700;color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:4px;padding:1px 6px;text-transform:uppercase;letter-spacing:.04em}
.grp-cat-badge{font-size:11px;color:var(--accent);background:var(--accent-bg);border-radius:4px;padding:1px 7px;font-weight:600;white-space:nowrap;max-width:100px;overflow:hidden;text-overflow:ellipsis}
.grp-names{width:100%;min-height:44px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:5px 7px;resize:vertical;font-family:inherit;background:var(--surface);line-height:1.55}
.grp-names:focus{outline:none;border-color:var(--border-focus)}
.grp-opts{display:flex;flex-direction:column;gap:5px;margin-top:6px;padding-top:6px;border-top:1px dashed var(--border)}
.opt-row{display:flex;align-items:center;gap:6px}
.opt-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);width:40px;flex-shrink:0}
.sep-chips{display:flex;gap:3px;flex-wrap:wrap}
.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)}
.sep-custom{width:52px;font-size:11px;border:1px solid var(--border);border-radius:4px;padding:2px 5px;background:var(--surface);text-align:center}
.sep-custom:focus{outline:none;border-color:var(--border-focus)}
.npl-input{width:46px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 5px;text-align:center;background:var(--surface)}
.npl-input:focus{outline:none;border-color:var(--border-focus)}
.npl-input:disabled{background:var(--surface-alt);color:var(--muted)}
/* ── 하위노드 영역 ────────────────────────── */
.children-area{display:flex;flex-direction:column;gap:6px}
.children-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
.children-list{display:flex;flex-direction:column;gap:8px;padding-left:12px;border-left:2px solid var(--border)}
.btn-add-child{align-self:flex-start;display:flex;align-items:center;gap:5px;font-size:11px;padding:4px 10px;background:var(--green-bg);color:var(--green);border:1px dashed var(--green);border-radius:6px;cursor:pointer;font-weight:600;transition:all .15s}
.btn-add-child:hover{background:var(--green);color:#fff;border-style:solid}
/* ── 미리보기 패널 ────────────────────────── */
.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:180px;font-family:monospace;font-size:12px;border:1.5px solid var(--border);border-radius:6px;padding:9px;resize:vertical;line-height:1.6}
.modal textarea:focus{outline:none;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)}
.sep{height:1px;background:var(--border);margin:10px 0}
input,textarea,button,select{font-family:inherit}
</style>
</head>
<body>
<header class="app-header">
<span style="font-size:18px">🏗️</span>
<h1>CTStruct 양식 빌더</h1>
<span class="badge">v0.3</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-label">✏️ 편집기</div>
<div class="builder-scroll">
<!-- 문서 루트 노드 -->
<div class="root-node">
<span class="root-lbl">문서</span>
<input class="node-mark-inp" id="rootMark" title="제목 구분자"
oninput="S.titleMark=this.value;updatePreview();save()">
<input class="root-title-inp" id="docTitle" placeholder="제목 없음"
oninput="S.title=this.value;updatePreview();save()">
</div>
<button class="btn-add-sec" onclick="addSection(null)"> 섹션 추가</button>
<div class="sec-list" id="secList"></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="sep"></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 DATA_TYPES = [
{v:'unset', lbl:'UNSET'},
{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:'list', lbl:'LIST 목록'},
{v:'dict', lbl:'DICT 맵'},
{v:'set', lbl:'SET 집합'},
{v:'markdown',lbl:'MARKDOWN'},
{v:'html', lbl:'HTML'},
{v:'json', lbl:'JSON'},
{v:'code', lbl:'CODE'},
{v:'custom', lbl:'CUSTOM'},
];
const SEP_PRESETS = [
{v:' ', lbl:'공백'},
{v:', ', lbl:', '},
{v:' / ', lbl:'/'},
{v:'\n', lbl:'↵ 줄바꿈'},
];
// ════════════════════════════════════════════════════════
// 팩토리
// ════════════════════════════════════════════════════════
function uid(){ return Math.random().toString(36).slice(2,9) }
function nodeDefaults(){
return { header:null, footer:null, prefix:null, suffix:null,
attributes:{}, data_type:'unset', value:null };
}
function mkSec(mark='', content='', lv=1){
return { id:uid(), lv, mark, content, ...nodeDefaults(), groups:[], children:[] };
}
function mkGrp(category='', names='', sep=' ', npl=0){
const attrs = category ? {category} : {};
return { id:uid(), content:null, ...nodeDefaults(),
attributes:attrs, names, sep, namesPerLine:npl };
}
// ════════════════════════════════════════════════════════
// 상태
// ════════════════════════════════════════════════════════
let S = {
titleMark: '✅',
title: '예시 양식',
sections: [
{ ...mkSec('🔹','1번 항목',1), groups:[mkGrp('','홍길동 홍길순 홍길영 홍길준\n홍길호 홍길윤')] },
{ ...mkSec('🔹','2번 항목',1), children:[
{ ...mkSec('🔸','2-1 항목',2), groups:[mkGrp('건강','홍길찬 홍길만')] },
{ ...mkSec('🔸','2-2 항목',2), groups:[mkGrp('직장','홍길주')] }
]}
]
};
let currentTab = 'text';
const openSettings = new Set();
// ── 영속성 ────────────────────────────────────────────
function save(){ try{ localStorage.setItem('ctstruct-builder', JSON.stringify(S)) }catch(_){} }
function load(){
try{
const raw = localStorage.getItem('ctstruct-builder');
if(raw){ S = JSON.parse(raw); migrateState(S); }
}catch(_){}
}
function migrateState(s){
if(!s.titleMark) s.titleMark = s.cfg?.titleMark ?? '✅';
const oldMarks = s.cfg?.marks ?? [];
if(s.cfg) delete s.cfg;
if(!s.sections) s.sections = [];
migrateSecList(s.sections, oldMarks);
}
function migrateSecList(secs, oldMarks){
for(const s of secs){
if(s.mark === undefined) s.mark = oldMarks[(s.lv??1)-1] ?? '';
if(s.name !== undefined){ s.content = s.content ?? s.name; delete s.name; }
if(!s.lv) s.lv = 1;
if(!s.groups) s.groups = [];
if(!s.children) s.children = [];
if(s.header === undefined) s.header = null;
if(s.footer === undefined) s.footer = null;
if(s.prefix === undefined) s.prefix = null;
if(s.suffix === undefined) s.suffix = null;
if(!s.attributes) s.attributes = {};
if(!s.data_type) s.data_type = 'unset';
if(s.value === undefined) s.value = null;
for(const g of s.groups){
if(g.cat !== undefined){ g.attributes=g.attributes||{}; if(g.cat) g.attributes.category=g.cat; delete g.cat; }
if(g.header === undefined) g.header = null;
if(g.footer === undefined) g.footer = null;
if(g.prefix === undefined) g.prefix = null;
if(g.suffix === undefined) g.suffix = null;
if(!g.attributes) g.attributes = {};
if(!g.data_type) g.data_type = 'unset';
if(g.value === undefined) g.value = null;
if(!g.sep) g.sep = ' ';
if(g.namesPerLine === undefined) g.namesPerLine = 0;
}
migrateSecList(s.children, oldMarks);
}
}
// ════════════════════════════════════════════════════════
// 노드 탐색 / 수정
// ════════════════════════════════════════════════════════
function findSec(list, id){
for(const s of list){ if(s.id===id) return s; const f=findSec(s.children,id); if(f) return f; } return null;
}
function findGrp(secs, id){
for(const s of secs){
for(const g of s.groups){ if(g.id===id) return g; }
const f=findGrp(s.children,id); if(f) return f;
}
return null;
}
function findNode(id){ return findSec(S.sections,id)||findGrp(S.sections,id); }
function setNodeField(nid, field, val){
const n=findNode(nid); if(!n) return;
n[field] = (val==='')?null:val;
updatePreview(); save();
}
// ── 어트리뷰트 ─────────────────────────────────────────
function addAttr(nid){
const n=findNode(nid); if(!n) return;
const newKey = Object.keys(n.attributes).includes('')? '_new'+Date.now() : '';
n.attributes[newKey]='';
renderSections(); updatePreview(); save();
setTimeout(()=>{
const rows=document.querySelectorAll(`[data-nid="${nid}"] .ns-attr-key`);
if(rows.length) rows[rows.length-1].focus();
},30);
}
function removeAttr(nid, key){
const n=findNode(nid); if(!n) return;
delete n.attributes[key];
renderSections(); updatePreview(); save();
}
function updateAttrKey(nid, oldKey, newKey){
const n=findNode(nid); if(!n||oldKey===newKey) return;
const val=n.attributes[oldKey]; delete n.attributes[oldKey]; n.attributes[newKey]=val;
updatePreview(); save();
}
function updateAttrVal(nid, key, val){
const n=findNode(nid); if(!n) return;
n.attributes[key]=val; updatePreview(); save();
}
// ── 노드 설정 패널 토글 ────────────────────────────────
function toggleSettings(nid){
openSettings.has(nid)? openSettings.delete(nid): openSettings.add(nid);
renderSections();
}
// ════════════════════════════════════════════════════════
// 섹션 CRUD
// ════════════════════════════════════════════════════════
function removeSec(list, id){ return list.filter(s=>s.id!==id).map(s=>({...s,children:removeSec(s.children,id)})) }
function addSection(parentId){
const parent = parentId ? findSec(S.sections, parentId) : null;
const lv = parent ? parent.lv+1 : 1;
const ns = mkSec('', '', lv);
if(!parent) S.sections.push(ns);
else parent.children.push(ns);
renderSections(); updatePreview(); save();
setTimeout(()=>{ const el=document.querySelector(`[data-sid="${ns.id}"] .sec-name-input`); if(el) el.focus(); },30);
}
function deleteSection(id){ S.sections=removeSec(S.sections,id); renderSections(); updatePreview(); save(); }
function moveSec(id, dir){
function move(list){ const i=list.findIndex(s=>s.id===id); if(i<0) return list.some(s=>move(s.children)); const j=i+dir; if(j<0||j>=list.length) return false; [list[i],list[j]]=[list[j],list[i]]; return true; }
move(S.sections); renderSections(); updatePreview(); save();
}
// ════════════════════════════════════════════════════════
// 그룹 CRUD
// ════════════════════════════════════════════════════════
function addGroup(sid){
const s=findSec(S.sections,sid); if(!s) return;
const ng=mkGrp(); s.groups.push(ng);
renderSections(); updatePreview(); save();
setTimeout(()=>{ const el=document.querySelector(`[data-gid="${ng.id}"] .grp-names`); if(el) el.focus(); },30);
}
function deleteGroup(sid, gid){
const s=findSec(S.sections,sid); if(!s) return;
s.groups=s.groups.filter(g=>g.id!==gid);
renderSections(); updatePreview(); save();
}
function moveGroup(sid, gid, dir){
const s=findSec(S.sections,sid); if(!s) return;
const i=s.groups.findIndex(g=>g.id===gid); const j=i+dir;
if(j<0||j>=s.groups.length) return;
[s.groups[i],s.groups[j]]=[s.groups[j],s.groups[i]];
renderSections(); updatePreview(); save();
}
function updateGroup(sid, gid, field, val){
const s=findSec(S.sections,sid); if(!s) return;
const g=s.groups.find(g=>g.id===gid); if(!g) return;
g[field]=val; updatePreview(); save();
}
// ════════════════════════════════════════════════════════
// 빌더 렌더링
// ════════════════════════════════════════════════════════
function esc(s){ return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') }
function hasNodeExtras(n){
return n.header||n.footer||n.prefix||n.suffix||n.value!==null||n.data_type!=='unset'||
Object.keys(n.attributes||{}).some(k=>k!=='level'&&k!=='category');
}
function renderGroups(sid, sec){
if(!sec.groups||!sec.groups.length) return '';
return sec.groups.map(g=>{
const sep=g.sep??' ', npl=g.namesPerLine??0, isNl=sep==='\n';
const catBadge=(g.attributes?.category)
?`<span class="grp-cat-badge">(${esc(g.attributes.category)})</span>`:'';
const sepChips=SEP_PRESETS.map(p=>`
<span class="sep-chip${sep===p.v?' on':''}"
onclick="updateGroup('${sid}','${g.id}','sep',${JSON.stringify(p.v)});renderSections();updatePreview();save()"
>${p.lbl}</span>`).join('');
const customVal=SEP_PRESETS.find(p=>p.v===sep)?'':esc(sep);
const grpOpen=openSettings.has(g.id);
const grpAttrs=Object.entries(g.attributes||{}).filter(([k])=>k!=='category');
const grpHasDot=hasNodeExtras(g);
const dtOpts=DATA_TYPES.map(d=>`<option value="${d.v}" ${g.data_type===d.v?'selected':''}>${d.lbl}</option>`).join('');
const grpAttrsHtml=grpAttrs.length?grpAttrs.map(([k,v])=>`
<div class="ns-attr-row">
<input class="ns-attr-key" placeholder="키" value="${esc(k)}"
onblur="updateAttrKey('${g.id}','${esc(k)}',this.value)">
<input class="ns-attr-val" placeholder="값" value="${esc(String(v??''))}"
oninput="updateAttrVal('${g.id}','${esc(k)}',this.value)">
<button class="iBtn" onclick="removeAttr('${g.id}','${esc(k)}')">✕</button>
</div>`).join(''):`<div class="ns-empty">없음</div>`;
const grpDecoHtml=[{f:'header',lbl:'머리글'},{f:'footer',lbl:'꼬리글'},{f:'prefix',lbl:'접두사'},{f:'suffix',lbl:'접미사'}]
.map(({f,lbl})=>`
<div class="ns-field">
<span class="ns-field-lbl">${lbl}</span>
<input class="ns-field-inp" placeholder="${f}" value="${esc(g[f]??'')}"
oninput="setNodeField('${g.id}','${f}',this.value)">
</div>`).join('');
return `
<div class="grp" data-gid="${g.id}">
<div class="grp-head">
<span class="grp-type-badge">LIST</span>
${catBadge}
<span style="flex:1"></span>
<button class="iBtn up" onclick="moveGroup('${sid}','${g.id}',-1)" title="위로">↑</button>
<button class="iBtn dn" onclick="moveGroup('${sid}','${g.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="deleteGroup('${sid}','${g.id}')" title="삭제">✕</button>
</div>
<textarea class="grp-names"
placeholder="이름 입력 (공백·줄바꿈·쉼표로 구분)"
oninput="updateGroup('${sid}','${g.id}','names',this.value)">${esc(g.names)}</textarea>
<div class="node-settings" data-nid="${g.id}" style="margin-top:6px">
<div class="ns-toggle${grpOpen?' open':''}" onclick="toggleSettings('${g.id}')">
<span class="ns-arrow">${grpOpen?'▼':'▶'}</span>
노드 설정
${grpHasDot?'<span class="ns-dot"></span>':''}
</div>
${grpOpen?`
<div class="ns-body">
<div class="ns-section">
<div class="ns-section-lbl">텍스트 장식</div>
<div class="ns-deco-grid">${grpDecoHtml}</div>
</div>
<div class="ns-section">
<div class="ns-section-lbl">
어트리뷰트
<button class="sBtn blue ns-add-btn" onclick="addAttr('${g.id}')"> 추가</button>
</div>
${grpAttrsHtml}
</div>
<div class="ns-section">
<div class="ns-section-lbl">값(value)</div>
<div class="ns-value-row">
<select class="ns-dtype" onchange="setNodeField('${g.id}','data_type',this.value)">${dtOpts}</select>
<input class="ns-value-inp" placeholder="값 입력"
value="${esc(String(g.value??''))}"
oninput="setNodeField('${g.id}','value',this.value)">
</div>
</div>
</div>`:''}
</div>
<div class="grp-opts">
<div class="opt-row">
<span class="opt-lbl">구분자</span>
<div class="sep-chips">${sepChips}</div>
<input class="sep-custom" placeholder="직접" value="${customVal}"
oninput="updateGroup('${sid}','${g.id}','sep',this.value||' ');updatePreview();save()">
</div>
<div class="opt-row">
<span class="opt-lbl">한 줄</span>
<input class="npl-input" type="number" min="0" step="1"
value="${npl}" ${isNl?'disabled':''}
oninput="updateGroup('${sid}','${g.id}','namesPerLine',Math.max(0,+this.value||0));updatePreview();save()">
<span style="font-size:11px;color:var(--muted)">${isNl?'(줄바꿈 모드)':'개 (0=제한없음)'}</span>
</div>
</div>
</div>`;
}).join('');
}
function renderNodeSettings(nid, node){
const open=openSettings.has(nid);
const isSection=Array.isArray(node.groups);
const attrs=Object.entries(node.attributes||{}).filter(([k])=>k!=='level'&&k!=='category');
const hasDot=hasNodeExtras(node)||(isSection&&node.groups.length>0);
const decoHtml=[
{f:'header',lbl:'머리글(header)'},
{f:'footer',lbl:'꼬리글(footer)'},
{f:'prefix',lbl:'접두사(prefix)'},
{f:'suffix',lbl:'접미사(suffix)'},
].map(({f,lbl})=>`
<div class="ns-field">
<span class="ns-field-lbl">${lbl}</span>
<input class="ns-field-inp" placeholder="${f}"
value="${esc(node[f]??'')}"
oninput="setNodeField('${nid}','${f}',this.value)">
</div>`).join('');
const attrsHtml=attrs.length?attrs.map(([k,v])=>`
<div class="ns-attr-row">
<input class="ns-attr-key" placeholder="키" value="${esc(k)}"
onblur="updateAttrKey('${nid}','${esc(k)}',this.value)">
<input class="ns-attr-val" placeholder="값" value="${esc(String(v??''))}"
oninput="updateAttrVal('${nid}','${esc(k)}',this.value)">
<button class="iBtn" onclick="removeAttr('${nid}','${esc(k)}')">✕</button>
</div>`).join(''):`<div class="ns-empty">없음</div>`;
const dtOpts=DATA_TYPES.map(d=>
`<option value="${d.v}" ${node.data_type===d.v?'selected':''}>${d.lbl}</option>`).join('');
return `
<div class="node-settings" data-nid="${nid}">
<div class="ns-toggle${open?' open':''}" onclick="toggleSettings('${nid}')">
<span class="ns-arrow">${open?'▼':'▶'}</span>
노드 설정
${hasDot?'<span class="ns-dot"></span>':''}
</div>
${open?`
<div class="ns-body">
${isSection?`
<div class="ns-section">
<div class="ns-section-lbl">
이름 목록
<button class="sBtn blue ns-add-btn" onclick="addGroup('${nid}')"> 추가</button>
</div>
${renderGroups(nid,node)||'<div class="ns-empty">없음</div>'}
</div>`:''}
<div class="ns-section">
<div class="ns-section-lbl">텍스트 장식</div>
<div class="ns-deco-grid">${decoHtml}</div>
</div>
<div class="ns-section">
<div class="ns-section-lbl">
어트리뷰트
<button class="sBtn blue ns-add-btn" onclick="addAttr('${nid}')"> 추가</button>
</div>
${attrsHtml}
</div>
<div class="ns-section">
<div class="ns-section-lbl">값(value)</div>
<div class="ns-value-row">
<select class="ns-dtype"
onchange="setNodeField('${nid}','data_type',this.value)">${dtOpts}</select>
<input class="ns-value-inp" placeholder="값 입력"
value="${esc(String(node.value??''))}"
oninput="setNodeField('${nid}','value',this.value)">
</div>
</div>
</div>`:''}
</div>`;
}
function renderSec(sec){
const childrenHtml=sec.children.map(c=>renderSec(c)).join('');
const hasChildren=sec.children.length>0;
return `
<div class="sec-card" data-sid="${sec.id}">
<div class="sec-head">
<input class="node-mark-inp" title="구분자" value="${esc(sec.mark??'')}"
oninput="setNodeField('${sec.id}','mark',this.value)">
<input class="sec-name-input" placeholder="섹션 이름"
value="${esc(sec.content??'')}"
oninput="setNodeField('${sec.id}','content',this.value)">
<button class="iBtn up" onclick="moveSec('${sec.id}',-1)" title="위로">↑</button>
<button class="iBtn dn" onclick="moveSec('${sec.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="deleteSection('${sec.id}')" title="삭제">✕</button>
</div>
<div class="sec-body">
${renderNodeSettings(sec.id, sec)}
<div class="children-area">
${hasChildren?`
<div class="children-lbl">하위노드</div>
<div class="children-list">${childrenHtml}</div>`:''}
<button class="btn-add-child" onclick="addSection('${sec.id}')"> 소항목</button>
</div>
</div>
</div>`;
}
function renderSections(){
document.getElementById('rootMark').value = S.titleMark;
document.getElementById('docTitle').value = S.title;
document.getElementById('secList').innerHTML = S.sections.map(s=>renderSec(s)).join('');
}
// ════════════════════════════════════════════════════════
// 텍스트 변환
// ════════════════════════════════════════════════════════
function parseNames(raw){ return raw.trim().split(/[\s,\n]+/).filter(n=>n.length>0) }
function toText(){
const lines=[];
const title=S.title.trim();
if(title){ lines.push(`${S.titleMark}${title}`); lines.push(''); }
function renderSec(sec){
lines.push(`${sec.mark??''}${sec.content||''}`);
for(const g of sec.groups){
const ns=parseNames(g.names); if(!ns.length) continue;
const cat=(g.attributes?.category??'').trim();
const pre=cat?`(${cat}) `:'';
const sep=g.sep??' ', npl=g.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('');
}
for(const c of sec.children) renderSec(c);
}
for(const s of S.sections) renderSec(s);
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){
function serGrp(g){
const ns=parseNames(g.names);
return buildNode({
id:g.id, type:'LIST',
content:g.content, header:g.header, footer:g.footer,
prefix:g.prefix, suffix:g.suffix,
attributes:{...g.attributes},
data_type:g.data_type, value:g.value,
children:ns.map(n=>buildNode({id:uid(),type:'LIST_ITEM',content:n}))
});
}
function serSec(sec){
return buildNode({
id:sec.id, type:'SECTION',
content:sec.content, header:sec.header, footer:sec.footer,
prefix:sec.prefix, suffix:sec.suffix,
attributes:{mark:sec.mark, level:sec.lv, ...sec.attributes},
data_type:sec.data_type, value:sec.value,
children:[...sec.groups.map(serGrp), ...sec.children.map(serSec)]
});
}
const root=buildNode({
id:'root', type:'ROOT',
attributes:S.title.trim()?{title:S.title.trim(),mark:S.titleMark}:{mark:S.titleMark},
children:S.sections.map(serSec)
});
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 content=toText();
if(!content.trim()){ el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; }
el.className='preview-text'; el.textContent=content;
} else {
const content=toJSON(currentTab==='json');
el.className='preview-text'; el.innerHTML=highlight(content);
}
}
// ════════════════════════════════════════════════════════
// 복사
// ════════════════════════════════════════════════════════
function copyOutput(){
const content=currentTab==='text'?toText():toJSON(currentTab==='json');
navigator.clipboard.writeText(content).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 collect(secs){ for(const s of secs){ if(s.mark&&!marks.includes(s.mark)) marks.push(s.mark); collect(s.children||[]); } }
collect(S.sections);
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 sortedMarks=marks.map((m,i)=>({m,lv:i+1})).sort((a,b)=>b.m.length-a.m.length);
const reCat=/^\(([^)]+)\)\s*(.*)/;
let title=''; const sections=[]; const secStack=[]; let pend=null;
function flush(){
if(!pend||!pend.names.length){ pend=null; return; }
const parent=pend.parentId?findSec(sections,pend.parentId):null;
const grp=mkGrp(pend.cat||'',pend.names.join(' '));
if(parent) parent.groups.push(grp); pend=null;
}
function topId(){ return secStack.length?secStack[secStack.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 sortedMarks){
if(line.startsWith(m)){
flush();
const sec=mkSec(m, line.slice(m.length).trim(), lv);
while(secStack.length){ const top=findSec(sections,secStack[secStack.length-1].id); if(top&&top.lv>=lv) secStack.pop(); else break; }
if(!secStack.length) sections.push(sec);
else{ const p=findSec(sections,secStack[secStack.length-1].id); if(p) p.children.push(sec); }
secStack.push({id:sec.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.sections=sections;
renderSections(); updatePreview(); save(); closeImport();
document.getElementById('importTxt').value='';
}
// ════════════════════════════════════════════════════════
// 새 문서
// ════════════════════════════════════════════════════════
function newDoc(){
if(!confirm('현재 내용을 모두 지우고 새 문서를 시작할까요?')) return;
S.title=''; S.sections=[]; openSettings.clear();
renderSections(); updatePreview(); save();
}
// ════════════════════════════════════════════════════════
// 초기화
// ════════════════════════════════════════════════════════
load();
renderSections();
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>