Files
CTStruct/ui/builder.html
gm c38d071a86 ui: add content filler tab (Step 3)
- New '내용 채우기' tab with full JSON template import workflow
- Load Builder JSON (slim or full) → parse ROOT/SECTION/VALUE tree
- Render form: each SECTION as a card, VALUE nodes as input fields
  (string → textarea, other types → input)
- Nested sections auto-indent with accent left-border via CSS
- Real-time text/JSON preview as values are typed
- Copy button for filled text or filled JSON output
- fillerToText() mirrors toText() logic using stored FV values
- fillerToJSON() reconstructs full JSON with FV values substituted
- Escape key + backdrop click close filler import modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:47:45 +09:00

1295 lines
64 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)}
/* ── 메인 탭 네비게이션 ───────────────────── */
.main-nav{background:var(--surface);border-bottom:2px solid var(--border);padding:0 20px;display:flex;gap:4px;flex-shrink:0}
.main-tab{font-size:13px;font-weight:600;padding:11px 18px;border:none;background:transparent;color:var(--muted);cursor:pointer;border-bottom:2.5px solid transparent;margin-bottom:-2px;transition:all .15s;display:flex;align-items:center;gap:6px;letter-spacing:.01em}
.main-tab:hover:not(.active){color:var(--text)}
.main-tab.active{color:var(--accent);border-bottom-color:var(--accent)}
/* ── 탭 패널 ─────────────────────────────── */
.tab-panel{flex:1;display:flex;overflow:hidden}
.tab-panel.hidden{display:none!important}
/* ── 레이아웃 ─────────────────────────────── */
.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:8px 12px 7px;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);display:flex;align-items:center;gap:6px}
.panel-lbl-title{flex:1}
.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}
/* ── Step 2: 캔버스 빌더 레이아웃 ─────────── */
#panel-builder{flex-direction:column}
.canvas-toolbar{background:var(--surface);border-bottom:1px solid var(--border);padding:8px 12px;display:flex;align-items:center;gap:8px;flex-shrink:0}
.canvas-toolbar .root-row{flex:1;margin:0;border:none;background:transparent;padding:0;box-shadow:none}
.canvas-toolbar .root-row:focus-within{border:none;background:transparent}
.builder-main{flex:1;display:flex;overflow:hidden}
.canvas-wrap{flex:1;overflow:auto;background:#eaecf2;position:relative}
.canvas-inner{position:relative}
.canvas-svg{position:absolute;top:0;left:0;pointer-events:none;overflow:visible}
/* 캔버스 노드 카드 */
.cnode{position:absolute;width:164px;background:var(--surface);border:1.5px solid var(--border);border-radius:var(--r);box-shadow:var(--sh);cursor:pointer;transition:border-color .15s,box-shadow .15s;user-select:none}
.cnode:hover{border-color:var(--border-focus)}
.cnode.selected{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,110,247,.12)}
.cnode-head{display:flex;align-items:center;gap:5px;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}
.cnode.selected .cnode-head{background:var(--accent-bg)}
.cnode-mark{font-size:14px;flex-shrink:0;line-height:1}
.cnode-name{font-size:12px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cnode-foot{display:flex;align-items:center;padding:4px 8px;gap:4px;min-height:28px}
.cnode-vals{font-size:10px;color:var(--muted);flex:1}
.cnode-btns{display:flex;gap:2px;opacity:0;transition:opacity .1s}
.cnode:hover .cnode-btns,.cnode.selected .cnode-btns{opacity:1}
.cnode-btn{width:18px;height:18px;display:flex;align-items:center;justify-content:center;border-radius:3px;border:none;cursor:pointer;font-size:10px;background:transparent;color:var(--muted);transition:all .1s;padding:0;flex-shrink:0}
.cnode-btn:hover{background:var(--accent-bg);color:var(--accent)}
.cnode-btn.del:hover{background:var(--red-bg);color:var(--red)}
.canvas-empty{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:var(--muted);pointer-events:none;line-height:1.8}
/* 우측 패널 */
.right-panel{width:400px;min-width:260px;display:flex;flex-direction:column;overflow:hidden;border-left:1px solid var(--border)}
.detail-panel{flex:1;overflow-y:auto;background:var(--surface);display:flex;flex-direction:column}
.detail-panel::-webkit-scrollbar{width:5px}
.detail-panel::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.detail-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px;font-style:italic;text-align:center;padding:20px}
.detail-hdr-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);padding:7px 12px 6px;border-bottom:1px solid var(--border);background:var(--surface-alt);flex-shrink:0}
.detail-node-head{display:flex;align-items:center;gap:5px;padding:8px 10px;border-bottom:1px solid var(--border);background:var(--surface-alt);flex-shrink:0}
.detail-body{padding:10px;display:flex;flex-direction:column;gap:8px}
/* ── Tab 2: 내용 채우기 ──────────────────── */
#panel-filler{flex-direction:column}
.filler-toolbar{background:var(--surface);border-bottom:1px solid var(--border);padding:8px 12px;display:flex;align-items:center;gap:8px;flex-shrink:0}
.filler-title{font-size:13px;font-weight:600;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 4px}
.filler-main{flex:1;display:flex;overflow:hidden}
.filler-form-wrap{flex:1;overflow-y:auto;padding:18px 20px;background:var(--bg)}
.filler-form-wrap::-webkit-scrollbar{width:5px}
.filler-form-wrap::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.filler-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:8px;color:var(--muted);text-align:center;line-height:1.7}
.filler-preview{width:400px;min-width:260px;display:flex;flex-direction:column;border-left:1px solid var(--border)}
/* 섹션 카드 */
.fsec{margin-bottom:10px}
.fsec-head{display:flex;align-items:center;gap:7px;padding:7px 10px;background:var(--surface);border:1.5px solid var(--border);border-radius:var(--r) var(--r) 0 0}
.fsec-head.alone{border-radius:var(--r)}
.fsec-body{padding:8px;background:var(--surface);border:1.5px solid var(--border);border-top:none;border-radius:0 0 var(--r) var(--r);display:flex;flex-direction:column;gap:6px}
.fsec-body>.fsec{margin-bottom:0}
.fsec-body>.fsec .fsec-head{border-left:3px solid var(--accent);padding-left:8px}
.fsec-mark{font-size:15px;flex-shrink:0;line-height:1}
.fsec-name{font-size:13px;font-weight:500}
/* 값 입력 */
.fval-row{display:flex;flex-direction:column;gap:3px}
.fval-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
.fval-inp{width:100%;font-size:13px;border:1.5px solid var(--border);border-radius:6px;padding:6px 8px;outline:none;font-family:inherit;background:var(--surface-alt);resize:vertical;line-height:1.5}
.fval-inp:focus{border-color:var(--border-focus);background:#fff}
</style>
</head>
<body>
<header class="app-header">
<span style="font-size:18px">🏗️</span>
<h1>CTStruct</h1>
<span class="badge">v0.5</span>
</header>
<nav class="main-nav">
<button class="main-tab active" id="mtab-builder" onclick="switchTab('builder')">✏️ 양식 만들기</button>
<button class="main-tab" id="mtab-filler" onclick="switchTab('filler')">📋 내용 채우기</button>
</nav>
<div id="panel-builder" class="tab-panel">
<div class="canvas-toolbar">
<span style="font-size:16px">🌳</span>
<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="hdr-btn" onclick="addTopNode()"> 노드 추가</button>
<button class="hdr-btn" onclick="openImport()">📥 불러오기</button>
<button class="hdr-btn" onclick="newDoc()">🗒 새 문서</button>
</div>
<div class="builder-main">
<div class="canvas-wrap" id="canvasWrap">
<div class="canvas-inner" id="canvasInner">
<svg id="canvasSvg" class="canvas-svg"></svg>
<div id="canvasNodes"></div>
</div>
</div>
<div class="right-panel">
<div class="detail-panel" id="detailPanel">
<div class="detail-empty">← 노드를 클릭하면<br>상세 편집이 여기에 표시됩니다</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>
</div><!-- /panel-builder -->
<div id="panel-filler" class="tab-panel hidden">
<div class="filler-toolbar">
<span style="font-size:16px">📋</span>
<span class="filler-title" id="fillerTitle">템플릿 없음</span>
<button class="hdr-btn" onclick="openFillerImport()">📂 JSON 불러오기</button>
<button class="hdr-btn" onclick="clearFiller()">🗑 지우기</button>
</div>
<div class="filler-main">
<div class="filler-form-wrap" id="fillerForm">
<div class="filler-empty">
<span style="font-size:40px">📋</span>
<p style="font-size:14px;font-weight:600">템플릿이 없습니다</p>
<p style="font-size:12px">위의 📂 JSON 불러오기 버튼으로<br>Builder에서 만든 양식 JSON을 가져오세요</p>
</div>
</div>
<div class="filler-preview">
<div class="preview-head">
<div class="tabs">
<button class="tab ftab active" id="ftab-text" onclick="setFillerTab('text')">텍스트</button>
<button class="tab ftab" id="ftab-json" onclick="setFillerTab('json')">JSON</button>
</div>
<div class="p-spacer"></div>
<button class="copy-btn" id="fCopyBtn" onclick="copyFillerOutput()">📋 복사</button>
</div>
<div class="preview-body">
<pre class="preview-text dim" id="fillerOut">내용을 채우면 미리보기가 표시됩니다</pre>
</div>
</div>
</div>
</div><!-- /panel-filler -->
<div class="overlay" id="fillerImportModal">
<div class="modal">
<h2>📂 템플릿 JSON 불러오기</h2>
<p>Builder의 <strong>JSON slim</strong> 또는 <strong>JSON full</strong> 출력을 붙여넣으세요.</p>
<textarea id="fillerImportTxt" placeholder='{"type":"ROOT", "children":[...]}'></textarea>
<div class="modal-actions">
<button class="mBtn cancel" onclick="closeFillerImport()">취소</button>
<button class="mBtn ok" onclick="doLoadFillerTemplate()">불러오기</button>
</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(){
const node=mkNode();
S.nodes.push(node);
selectedNodeId=node.id;
refresh();
}
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);
if(type==='node') selectedNodeId=v.node.id;
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(); renderCanvas(); renderDetail(); updatePreview(); save(); }
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') }
// ════════════════════════════════════════════════════════
// 캔버스 레이아웃
// ════════════════════════════════════════════════════════
let selectedNodeId = null;
const C_W=164, C_H=54, C_COL=252, C_ROW=72, C_PAD=28;
function subtreeH(node){
const kids=node.values.filter(v=>v.type==='node').map(v=>v.node);
if(!kids.length) return 1;
return kids.reduce((s,c)=>s+subtreeH(c),0);
}
function computeLayout(){
const pos=new Map();
function place(node,col,ySlot){
const kids=node.values.filter(v=>v.type==='node').map(v=>v.node);
const h=subtreeH(node);
pos.set(node.id,{x:C_PAD+col*C_COL, y:C_PAD+C_ROW*(ySlot+h/2)-C_H/2});
let y=ySlot;
for(const kid of kids){ place(kid,col+1,y); y+=subtreeH(kid); }
}
let y=0;
for(const node of S.nodes){ place(node,0,y); y+=subtreeH(node); }
return pos;
}
function renderCanvas(){
rebuildMaps();
document.getElementById('rootMark').value=S.titleMark;
document.getElementById('docTitle').value=S.title;
if(S.nodes.length===0){
document.getElementById('canvasNodes').innerHTML=
`<div class="canvas-empty"><div style="font-size:38px">🌱</div><div style="margin-top:8px;font-size:13px;font-weight:500">노드가 없습니다</div><div style="font-size:11px;margin-top:4px;color:var(--muted)">위의 노드 추가 버튼으로 시작하세요</div></div>`;
document.getElementById('canvasSvg').innerHTML='';
return;
}
const pos=computeLayout();
const wrap=document.getElementById('canvasWrap');
const inner=document.getElementById('canvasInner');
let maxX=wrap.clientWidth||600, maxY=wrap.clientHeight||400;
pos.forEach(({x,y})=>{ maxX=Math.max(maxX,x+C_W+C_PAD); maxY=Math.max(maxY,y+C_H+C_PAD); });
inner.style.width=maxX+'px'; inner.style.height=maxY+'px';
// SVG 연결선
let paths='';
function drawLines(node){
const p=pos.get(node.id); if(!p) return;
const px=p.x+C_W, py=p.y+C_H/2;
const kids=node.values.filter(v=>v.type==='node').map(v=>v.node);
for(const kid of kids){
const c=pos.get(kid.id); if(!c) continue;
const cx=c.x, cy=c.y+C_H/2;
const mx=(px+cx)/2;
const sel=(node.id===selectedNodeId||kid.id===selectedNodeId);
paths+=`<path d="M${px},${py} C${mx},${py} ${mx},${cy} ${cx},${cy}" fill="none" stroke="${sel?'#4f6ef7':'#c5cae0'}" stroke-width="${sel?2:1.5}"/>`;
drawLines(kid);
}
}
for(const node of S.nodes) drawLines(node);
const svg=document.getElementById('canvasSvg');
svg.style.width=maxX+'px'; svg.style.height=maxY+'px';
svg.setAttribute('viewBox',`0 0 ${maxX} ${maxY}`);
svg.innerHTML=paths;
// 노드 카드
let cards='';
function renderCNode(node){
const p=pos.get(node.id); if(!p) return;
const sel=node.id===selectedNodeId;
const vals=node.values.filter(v=>v.type!=='node').length;
const kids=node.values.filter(v=>v.type==='node').length;
const hint=[vals?`${vals}`:'',kids?`하위 ${kids}`:''].filter(Boolean).join(' · ')||'비어있음';
cards+=`<div class="cnode${sel?' selected':''}" style="left:${p.x}px;top:${p.y}px" onclick="selectNode('${node.id}')">
<div class="cnode-head">
<span class="cnode-mark">${esc(node.mark||'·')}</span>
<span class="cnode-name" title="${esc(node.content||'')}">${esc(node.content||'이름 없음')}</span>
</div>
<div class="cnode-foot">
<span class="cnode-vals">${hint}</span>
<div class="cnode-btns">
<button class="cnode-btn" title="위로" onclick="event.stopPropagation();moveNode('${node.id}',-1)">↑</button>
<button class="cnode-btn" title="아래로" onclick="event.stopPropagation();moveNode('${node.id}',1)">↓</button>
<button class="cnode-btn" title="하위 노드 추가" onclick="event.stopPropagation();addChildNode('${node.id}')"></button>
<button class="cnode-btn del" title="삭제" onclick="event.stopPropagation();deleteFromCanvas('${node.id}')">✕</button>
</div>
</div>
</div>`;
for(const v of node.values) if(v.type==='node') renderCNode(v.node);
}
for(const node of S.nodes) renderCNode(node);
document.getElementById('canvasNodes').innerHTML=cards;
}
function selectNode(nid){
selectedNodeId=nid;
renderCanvas();
renderDetail();
}
function addChildNode(parentNid){
const parent=nodeMap[parentNid]; if(!parent) return;
const child=mkNode();
parent.values.push(mkNodeVal(child));
selectedNodeId=child.id;
refresh();
}
function deleteFromCanvas(nid){
if(nid===selectedNodeId) selectedNodeId=null;
deleteNode(nid);
}
function renderDetail(){
const panel=document.getElementById('detailPanel'); if(!panel) return;
if(!selectedNodeId||!nodeMap[selectedNodeId]){
panel.innerHTML='<div class="detail-empty">← 노드를 클릭하면<br>상세 편집이 여기에 표시됩니다</div>';
return;
}
const node=nodeMap[selectedNodeId];
const valItems=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('');
panel.innerHTML=`
<div class="detail-hdr-lbl">📌 노드 편집</div>
<div class="detail-node-head">
<input class="mark-inp" title="구분자" value="${esc(node.mark??'')}"
oninput="nodeMap['${node.id}'].mark=this.value;updatePreview();save();renderCanvas()">
<input class="name-inp" placeholder="노드 이름" value="${esc(node.content??'')}"
oninput="nodeMap['${node.id}'].content=this.value;updatePreview();save();renderCanvas()">
<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="deleteFromCanvas('${node.id}')" title="삭제">✕</button>
</div>
<div class="detail-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>
${valItems?`<div class="body-sec-body">${valItems}</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>`;
}
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'){
const cn=val.node;
return `<div class="val-item" data-vid="${val.id}">
<div class="val-head">
<span class="val-type-badge">NODE</span>
<span style="flex:1;font-size:12px;padding:0 5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc((cn.mark||'')+' '+(cn.content||'이름 없음'))}</span>
<button class="iBtn" title="이 노드 선택" onclick="event.stopPropagation();selectNode('${cn.id}')">→</button>
<button class="iBtn mv" onclick="event.stopPropagation();moveValue('${val.id}',-1)" title="위로">↑</button>
<button class="iBtn mv" onclick="event.stopPropagation();moveValue('${val.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="event.stopPropagation();removeValue('${val.id}')" title="삭제">✕</button>
</div>
</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();
}
// ════════════════════════════════════════════════════════
// Tab 2: 내용 채우기
// ════════════════════════════════════════════════════════
let FT = null; // filler template (parsed JSON root)
let FV = {}; // filler values: nodeId → string
let fillerTab = 'text';
function openFillerImport(){ document.getElementById('fillerImportModal').classList.add('open') }
function closeFillerImport(){ document.getElementById('fillerImportModal').classList.remove('open') }
function doLoadFillerTemplate(){
const raw=document.getElementById('fillerImportTxt').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; }
FT=obj; FV={};
initFillerValues(FT);
closeFillerImport();
document.getElementById('fillerImportTxt').value='';
renderFiller();
}
function initFillerValues(node){
for(const child of node.children??[]){
if(child.type==='VALUE'){
if(!child.id) child.id='fv_'+Math.random().toString(36).slice(2);
FV[child.id]=String(child.value??'');
} else if(child.type==='SECTION'){
if(!child.id) child.id='fs_'+Math.random().toString(36).slice(2);
initFillerValues(child);
}
}
}
function clearFiller(){
if(!confirm('템플릿을 지우고 처음부터 시작할까요?')) return;
FT=null; FV={};
renderFiller();
}
function renderFiller(){
const form=document.getElementById('fillerForm');
const titleEl=document.getElementById('fillerTitle');
const outEl=document.getElementById('fillerOut');
if(!FT){
titleEl.textContent='템플릿 없음';
form.innerHTML=`<div class="filler-empty">
<span style="font-size:40px">📋</span>
<p style="font-size:14px;font-weight:600">템플릿이 없습니다</p>
<p style="font-size:12px">위의 📂 JSON 불러오기 버튼으로<br>Builder에서 만든 양식 JSON을 가져오세요</p>
</div>`;
outEl.className='preview-text dim'; outEl.textContent='내용을 채우면 미리보기가 표시됩니다';
return;
}
const title=FT.attributes?.title??'(제목 없음)';
const mark=FT.attributes?.mark??'';
titleEl.textContent=(mark?mark+' ':'')+title;
let html='';
for(const child of FT.children??[]) if(child.type==='SECTION') html+=renderFillerSection(child);
form.innerHTML=html||'<div class="filler-empty"><p style="font-size:12px">양식에 섹션이 없습니다</p></div>';
updateFillerPreview();
}
function renderFillerSection(sec){
const mark=sec.attributes?.mark??'';
const name=sec.content??'';
let body='';
for(const child of sec.children??[]){
if(child.type==='VALUE'){
const type=child.data_type??'string';
const id=child.id;
const val=FV[id]??'';
body+=`<div class="fval-row">
<span class="fval-lbl">${esc(type)}</span>
${type==='string'
?`<textarea class="fval-inp" rows="2" oninput="FV['${id}']=this.value;updateFillerPreview()">${esc(val)}</textarea>`
:`<input class="fval-inp" value="${esc(val)}" oninput="FV['${id}']=this.value;updateFillerPreview()">`}
</div>`;
} else if(child.type==='SECTION'){
body+=renderFillerSection(child);
}
}
return `<div class="fsec">
<div class="fsec-head${!body?' alone':''}">
<span class="fsec-mark">${esc(mark||'·')}</span>
<span class="fsec-name">${esc(name||'이름 없음')}</span>
</div>
${body?`<div class="fsec-body">${body}</div>`:''}
</div>`;
}
function updateFillerPreview(){
const el=document.getElementById('fillerOut'); if(!FT) return;
if(fillerTab==='text'){
const t=fillerToText();
el.className=t.trim()?'preview-text':'preview-text dim';
el.textContent=t.trim()||'내용을 채우면 미리보기가 표시됩니다';
} else {
el.className='preview-text';
el.innerHTML=highlight(fillerToJSON());
}
}
function setFillerTab(t){
fillerTab=t;
document.querySelectorAll('.ftab').forEach(el=>el.classList.remove('active'));
document.getElementById('ftab-'+t).classList.add('active');
updateFillerPreview();
}
function copyFillerOutput(){
const txt=fillerTab==='text'?fillerToText():fillerToJSON();
navigator.clipboard.writeText(txt).then(()=>{
const btn=document.getElementById('fCopyBtn');
btn.textContent='✅ 복사됨'; btn.classList.add('ok');
setTimeout(()=>{ btn.innerHTML='📋 복사'; btn.classList.remove('ok'); },2000);
});
}
function fillerToText(){
if(!FT) return '';
function rSec(sec){
const mark=sec.attributes?.mark??'';
const content=sec.content??'';
const prefix=sec.prefix??'';
const suffix=sec.suffix??'';
const nl=sec.nameNewline!==false?'\n':'';
let t=prefix+mark+content;
let first=true;
for(const child of sec.children??[]){
if(child.type==='SECTION'){
t+=rSec(child); first=false;
} else if(child.type==='VALUE'){
const val=FV[child.id]??'';
if(val.trim()){ t+=(first?nl:'\n')+val; first=false; }
}
}
t+=suffix;
return t;
}
const title=FT.attributes?.title??'';
const titleMark=FT.attributes?.mark??'';
let out=title?`${titleMark}${title}\n`:'';
for(const child of FT.children??[]) if(child.type==='SECTION') out+=rSec(child);
return out.replace(/\n{3,}/g,'\n\n').trimEnd()+'\n';
}
function fillerToJSON(){
if(!FT) return '{}';
function fill(node){
const r={...node};
r.children=(node.children??[]).map(child=>{
if(child.type==='VALUE') return {...child, value:FV[child.id]??child.value??null};
if(child.type==='SECTION') return fill(child);
return child;
});
return r;
}
return JSON.stringify(fill(FT),null,2);
}
// ════════════════════════════════════════════════════════
// 탭 전환
// ════════════════════════════════════════════════════════
const TAB_IDS = ['builder', 'filler'];
function switchTab(name){
TAB_IDS.forEach(id => {
document.getElementById('panel-'+id).classList.toggle('hidden', id !== name);
document.getElementById('mtab-'+id).classList.toggle('active', id === name);
});
}
// ════════════════════════════════════════════════════════
// 초기화
// ════════════════════════════════════════════════════════
load();
refresh();
document.getElementById('importModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeImport(); });
document.getElementById('fillerImportModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeFillerImport(); });
document.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeImport(); closeFillerImport(); } });
</script>
</body>
</html>