Files
CTStruct/ui/builder.html
gm 3940d0c0ea refactor(ui): node-centric settings panel redesign
데이터 모델
- sec.name → sec.content (CTNode content 필드와 통일)
- grp.cat → grp.attributes.category (어트리뷰트 편집기로 통합)
- 모든 노드(SECTION, LIST)에 CTNode 기본 필드 추가:
  header / footer / prefix / suffix / data_type / value

노드 설정 패널 (▶ 노드 설정, 접이식)
- 텍스트 장식: header / footer / prefix / suffix
- 어트리뷰트: key-value 동적 추가·수정·삭제 (level은 자동 관리)
- 값: DataType 드롭다운(17종) + value 입력
- 비기본값 설정 시 파란 점(●) 표시
- openSettings Set으로 접이 상태 렌더 간 유지

기타
- mkSec / mkGrp / nodeDefaults 팩토리로 기본값 통일
- migrate()로 구버전 localStorage 자동 변환
- toJSON: header/footer/prefix/suffix/data_type/value 포함
- 구분자·한줄 옵션은 그룹 하단에 유지

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

846 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CTStruct 양식 빌더</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f0f2f5;--surface:#fff;--surface-alt:#f8f9fa;
--border:#e2e5ea;--border-focus:#4f6ef7;
--text:#1a1d23;--muted:#7c8594;
--accent:#4f6ef7;--accent-bg:#eef0ff;
--red:#ef4444;--red-bg:#fee2e2;
--green:#16a34a;--green-bg:#dcfce7;
--l1:#4f6ef7;--l2:#8b5cf6;--l3:#d97706;
--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}
/* ── 구분자 설정 카드 ─────────────────────── */
.config-card{background:var(--surface-alt);border:1px solid var(--border);border-radius:var(--r);padding:11px 13px;margin-bottom:14px}
.config-card-title{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:10px;display:flex;align-items:center;gap:5px}
.config-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.config-row:last-child{margin-bottom:0}
.config-lbl{font-size:12px;color:var(--muted);width:48px;flex-shrink:0}
.mark-input{width:56px;text-align:center;font-size:15px;border:1px solid var(--border);border-radius:6px;padding:3px 6px;background:var(--surface)}
.marks-wrap{display:flex;flex-wrap:wrap;gap:5px;flex:1}
.mark-chip{display:flex;align-items:center;gap:3px;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:2px 5px}
.mark-chip .lvl{font-size:9px;color:var(--muted);font-weight:700}
.mark-chip input{width:46px;font-size:14px;border:none;outline:none;text-align:center;background:transparent}
.mark-chip .rm{cursor:pointer;color:var(--muted);font-size:11px;padding:0 2px;line-height:1}
.mark-chip .rm:hover{color:var(--red)}
.btn-add-mark{font-size:11px;padding:3px 9px;background:var(--accent-bg);color:var(--accent);border:1px dashed var(--accent);border-radius:6px;cursor:pointer;font-weight:600;align-self:center}
.btn-add-mark:hover{background:var(--accent);color:#fff;border-style:solid}
/* ── 제목 입력 ────────────────────────────── */
.field-label{font-size:11px;font-weight:600;color:var(--muted);margin-bottom:5px;letter-spacing:.04em;text-transform:uppercase}
.title-wrap{display:flex;align-items:center;gap:7px;background:var(--surface-alt);border:1.5px solid var(--border);border-radius:var(--r);padding:7px 11px;margin-bottom:14px;transition:border-color .15s}
.title-wrap:focus-within{border-color:var(--border-focus);background:var(--surface)}
.title-mark{font-size:16px;flex-shrink:0}
.title-wrap input{border:none;outline:none;font-size:13px;flex:1;background:transparent;font-weight:500}
.title-wrap input::placeholder{color:var(--muted);font-weight:400}
/* ── 버튼 ─────────────────────────────────── */
.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}
.sBtn.green{background:var(--green-bg);color:var(--green);border-color:var(--green)}
.sBtn.green:hover{background:var(--green);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);overflow:visible}
.sec-card[data-level="1"]{border-left:3px solid var(--l1)}
.sec-card[data-level="2"]{border-left:3px solid var(--l2);margin-left:14px}
.sec-card[data-level="3"]{border-left:3px solid var(--l3);margin-left:28px}
.sec-head{display:flex;align-items:center;gap:7px;padding:7px 9px;background:var(--surface-alt);border-bottom:1px solid var(--border)}
.sec-mark{font-size:13px;flex-shrink: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:9px}
.sec-actions{display:flex;gap:5px;flex-wrap:wrap;margin-top:2px}
.children-list{display:flex;flex-direction:column;gap:8px;margin-top:9px}
/* ── 그룹(LIST) 카드 ──────────────────────── */
.grp{border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:7px;background:var(--surface-alt)}
.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)}
/* ── 노드 설정 패널 ───────────────────────── */
.node-settings{margin:6px 0}
.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}
/* ── 미리보기 패널 ────────────────────────── */
.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 textarea{width:100%;height:200px;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:12px 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.2</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="config-card">
<div class="config-card-title">⚙️ 구분자 설정</div>
<div class="config-row">
<span class="config-lbl">제목</span>
<input id="cfgTitle" class="mark-input" value="✅" oninput="syncConfig()">
</div>
<div class="config-row" style="align-items:flex-start">
<span class="config-lbl" style="padding-top:5px">섹션</span>
<div class="marks-wrap" id="marksList"></div>
<button class="btn-add-mark" onclick="addMark()">+ 레벨</button>
</div>
</div>
<div class="field-label">문서 제목</div>
<div class="title-wrap">
<span class="title-mark" id="titleMarkBadge"></span>
<input id="docTitle" placeholder="제목을 입력하세요" oninput="updatePreview()">
</div>
<div class="sep"></div>
<button class="btn-add-sec" onclick="addSection(null,1)"> 섹션 추가</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>
<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) }
/** CTNode 기본 필드 */
function nodeDefaults(){
return { header:null, footer:null, prefix:null, suffix:null,
attributes:{}, data_type:'unset', value:null };
}
/** SECTION 노드 */
function mkSec(lv, content=''){
return { id:uid(), lv, content, ...nodeDefaults(), groups:[], children:[] };
}
/** LIST 노드 (names/sep/namesPerLine 은 표시 설정) */
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 = {
cfg: { titleMark:'✅', marks:['🔹','🔸'] },
title: '예시 양식',
sections: [
{ ...mkSec(1,'1번 항목'), groups:[mkGrp('','홍길동 홍길순 홍길영 홍길준\n홍길호 홍길윤')] },
{ ...mkSec(1,'2번 항목'), children:[
{ ...mkSec(2,'2-1 항목'), groups:[mkGrp('건강','홍길찬 홍길만')] },
{ ...mkSec(2,'2-2 항목'), groups:[mkGrp('직장','홍길주')] }
]}
]
};
let currentTab = 'text';
const openSettings = new Set(); // 열려 있는 노드 설정 패널 IDs
// ── 영속성 ────────────────────────────────────────────
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); migrate(S.sections); }
}catch(_){}
}
/** 구버전(name/cat 필드) → 현재 모델 마이그레이션 */
function migrate(secs){
for(const s of secs){
if(s.name !== undefined){ s.content = s.content ?? s.name; delete s.name; }
if(!s.header) s.header = null;
if(!s.footer) s.footer = null;
if(!s.prefix) s.prefix = null;
if(!s.suffix) 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) g.header = null;
if(!g.footer) g.footer = null;
if(!g.prefix) g.prefix = null;
if(!g.suffix) 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;
}
migrate(s.children||[]);
}
}
// ════════════════════════════════════════════════════════
// 설정 (구분자)
// ════════════════════════════════════════════════════════
function syncConfig(){
S.cfg.titleMark = document.getElementById('cfgTitle').value || '✅';
document.getElementById('titleMarkBadge').textContent = S.cfg.titleMark;
renderMarks();
renderSections();
updatePreview();
save();
}
function addMark(){ S.cfg.marks.push(''); renderMarks(); renderSections(); updatePreview(); save(); }
function removeMark(i){ if(S.cfg.marks.length<=1) return; S.cfg.marks.splice(i,1); renderMarks(); renderSections(); updatePreview(); save(); }
function setMark(i,v){ S.cfg.marks[i]=v; renderSections(); updatePreview(); save(); }
function renderMarks(){
document.getElementById('marksList').innerHTML = S.cfg.marks.map((m,i)=>`
<div class="mark-chip">
<span class="lvl">L${i+1}</span>
<input value="${esc(m)}" placeholder="마커" oninput="setMark(${i},this.value)">
${S.cfg.marks.length>1?`<span class="rm" onclick="removeMark(${i})">✕</span>`:''}
</div>`).join('');
}
// ════════════════════════════════════════════════════════
// 노드 탐색 / 수정
// ════════════════════════════════════════════════════════
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 found=findGrp(s.children,id); if(found) return found;
}
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();
// 새로 생긴 키 input에 포커스
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, lv){
const ns=mkSec(lv);
if(!parentId) S.sections.push(ns);
else{ const p=findSec(S.sections,parentId); if(p) p.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(); }
// ════════════════════════════════════════════════════════
// 노드 설정 패널 HTML
// ════════════════════════════════════════════════════════
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 renderNodeSettings(nid, node){
const open = openSettings.has(nid);
const attrs = Object.entries(node.attributes||{}).filter(([k])=>k!=='level');
const hasDot = hasNodeExtras(node);
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">
<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>
<div>${attrsHtml}</div>
</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 esc(s){ return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') }
function renderSec(sec){
const mark = S.cfg.marks[sec.lv-1]??'';
const maxLv = S.cfg.marks.length;
const canChild= sec.lv<maxLv;
const grpsHtml = sec.groups.map(g=>{
const sep = g.sep??' ';
const npl = g.namesPerLine??0;
const 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('${sec.id}','${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);
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('${sec.id}','${g.id}',-1)" title="위로">↑</button>
<button class="iBtn dn" onclick="moveGroup('${sec.id}','${g.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="deleteGroup('${sec.id}','${g.id}')" title="삭제">✕</button>
</div>
<textarea class="grp-names"
placeholder="이름 입력 (공백·줄바꿈·쉼표로 구분)"
oninput="updateGroup('${sec.id}','${g.id}','names',this.value)">${esc(g.names)}</textarea>
${renderNodeSettings(g.id, g)}
<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('${sec.id}','${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('${sec.id}','${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('');
const childrenHtml = sec.children.map(c=>renderSec(c)).join('');
return `
<div class="sec-card" data-level="${sec.lv}" data-sid="${sec.id}">
<div class="sec-head">
<span class="sec-mark">${esc(mark)}</span>
<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)}
${grpsHtml}
<div class="sec-actions">
<button class="sBtn blue" onclick="addGroup('${sec.id}')"> 이름 그룹</button>
${canChild?`<button class="sBtn green" onclick="addSection('${sec.id}',${sec.lv+1})"> 소항목 (L${sec.lv+1})</button>`:''}
</div>
${childrenHtml?`<div class="children-list">${childrenHtml}</div>`:''}
</div>
</div>`;
}
function renderSections(){
renderMarks();
document.getElementById('titleMarkBadge').textContent = S.cfg.titleMark;
document.getElementById('cfgTitle').value = S.cfg.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 {titleMark, marks} = S.cfg;
const lines = [];
const title = S.title.trim();
if(title){ lines.push(`${titleMark}${title}`); lines.push(''); }
function renderSec(sec){
const mark = marks[sec.lv-1]??'';
lines.push(`${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??' ';
const 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:{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()}:{},
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(){
S.title = document.getElementById('docTitle')?.value??'';
const el = document.getElementById('previewOut');
let content='';
if(currentTab==='text'){
content=toText();
if(!content.trim()){ el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; }
el.className='preview-text'; el.textContent=content;
} else {
content=toJSON(currentTab==='json');
if(!content){ el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; }
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);
});
}
// ════════════════════════════════════════════════════════
// 불러오기 (EmojiMarkupParser JS 포트)
// ════════════════════════════════════════════════════════
function openImport(){ 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=S.cfg.titleMark;
const sortedMarks=[...S.cfg.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(lv, line.slice(m.length).trim());
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.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>