각 노드가 독립적인 구분자를 가지도록 전면 개편. 전역 구분자 설정 카드 제거. 노드 카드 구조: 구분자·제목·노드설정·하위노드. 이름 목록(LIST 그룹)을 노드설정 패널 안으로 통합. 불러오기 모달에 구분자 직접 입력 추가. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
864 lines
45 KiB
HTML
864 lines
45 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>CTStruct 양식 빌더</title>
|
||
<style>
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
--bg:#f0f2f5;--surface:#fff;--surface-alt:#f8f9fa;
|
||
--border:#e2e5ea;--border-focus:#4f6ef7;
|
||
--text:#1a1d23;--muted:#7c8594;
|
||
--accent:#4f6ef7;--accent-bg:#eef0ff;
|
||
--red:#ef4444;--red-bg:#fee2e2;
|
||
--green:#16a34a;--green-bg:#dcfce7;
|
||
--r:8px;--sh:0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.05);
|
||
--dk:#1e1e2e;--dk2:#181825;--dk3:#313244;--dk4:#45475a;
|
||
--dt:#cdd6f4;--dt2:#bac2de;--dt3:#6c7086;
|
||
}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;flex-direction:column;font-size:14px}
|
||
|
||
/* ── 헤더 ─────────────────────────────────── */
|
||
.app-header{background:var(--surface);border-bottom:1px solid var(--border);padding:10px 16px;display:flex;align-items:center;gap:10px;box-shadow:var(--sh);z-index:10;flex-shrink:0}
|
||
.app-header h1{font-size:15px;font-weight:600}
|
||
.badge{font-size:10px;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:99px;font-weight:600}
|
||
.hdr-spacer{flex:1}
|
||
.hdr-btn{display:flex;align-items:center;gap:5px;font-size:12px;padding:5px 12px;border-radius:6px;cursor:pointer;border:1px solid var(--border);background:var(--surface);color:var(--muted);transition:all .15s;font-weight:500}
|
||
.hdr-btn:hover{background:var(--surface-alt);color:var(--text);border-color:var(--border-focus)}
|
||
|
||
/* ── 레이아웃 ─────────────────────────────── */
|
||
.app-body{display:flex;flex:1;overflow:hidden}
|
||
.builder-panel{width:460px;min-width:340px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
|
||
.panel-label{padding:10px 16px 8px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);border-bottom:1px solid var(--border);flex-shrink:0;background:var(--surface-alt)}
|
||
.builder-scroll{flex:1;overflow-y:auto;padding:14px}
|
||
.builder-scroll::-webkit-scrollbar{width:5px}
|
||
.builder-scroll::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||
|
||
/* ── 문서 루트 노드 ───────────────────────── */
|
||
.root-node{display:flex;align-items:center;gap:8px;background:var(--surface-alt);border:1.5px solid var(--border);border-radius:var(--r);padding:7px 11px;margin-bottom:12px;transition:border-color .15s}
|
||
.root-node:focus-within{border-color:var(--border-focus);background:var(--surface)}
|
||
.root-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex-shrink:0}
|
||
.root-title-inp{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent}
|
||
.root-title-inp::placeholder{color:var(--muted);font-weight:400}
|
||
|
||
/* ── 구분자 입력 (공통) ───────────────────── */
|
||
.node-mark-inp{width:36px;text-align:center;font-size:15px;border:1px solid var(--border);border-radius:5px;padding:2px 3px;background:var(--surface);flex-shrink:0;outline:none}
|
||
.node-mark-inp:focus{border-color:var(--border-focus)}
|
||
|
||
/* ── 버튼 ─────────────────────────────────── */
|
||
.btn-add-sec{display:flex;align-items:center;justify-content:center;gap:7px;width:100%;padding:9px;background:var(--accent-bg);color:var(--accent);border:1.5px dashed var(--accent);border-radius:var(--r);cursor:pointer;font-size:12px;font-weight:600;margin-bottom:12px;transition:all .15s}
|
||
.btn-add-sec:hover{background:var(--accent);color:#fff;border-style:solid}
|
||
.iBtn{width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:4px;border:none;cursor:pointer;font-size:12px;background:transparent;color:var(--muted);transition:all .1s;flex-shrink:0}
|
||
.iBtn:hover{background:var(--red-bg);color:var(--red)}
|
||
.iBtn.up:hover,.iBtn.dn:hover{background:var(--accent-bg);color:var(--accent)}
|
||
.sBtn{font-size:11px;padding:3px 9px;border-radius:4px;border:1px solid;cursor:pointer;font-weight:600;transition:all .12s}
|
||
.sBtn.blue{background:var(--accent-bg);color:var(--accent);border-color:var(--accent)}
|
||
.sBtn.blue:hover{background:var(--accent);color:#fff}
|
||
|
||
/* ── 섹션 카드 ────────────────────────────── */
|
||
.sec-list{display:flex;flex-direction:column;gap:9px}
|
||
.sec-card{border:1.5px solid var(--border);border-radius:var(--r);background:var(--surface);box-shadow:var(--sh)}
|
||
.sec-head{display:flex;align-items:center;gap:6px;padding:7px 9px;background:var(--surface-alt);border-bottom:1px solid var(--border);border-radius:calc(var(--r) - 1.5px) calc(var(--r) - 1.5px) 0 0}
|
||
.sec-name-input{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent;min-width:0}
|
||
.sec-name-input::placeholder{color:var(--muted);font-weight:400}
|
||
.sec-body{padding:8px 9px 9px;display:flex;flex-direction:column;gap:8px}
|
||
|
||
/* ── 노드 설정 패널 ───────────────────────── */
|
||
.node-settings{}
|
||
.ns-toggle{display:flex;align-items:center;gap:5px;padding:5px 9px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);cursor:pointer;background:var(--surface-alt);border:1px solid var(--border);border-radius:5px;transition:all .12s;user-select:none}
|
||
.ns-toggle:hover{color:var(--accent);border-color:var(--accent);background:var(--accent-bg)}
|
||
.ns-toggle.open{border-radius:5px 5px 0 0;border-bottom-color:transparent;color:var(--accent);background:var(--accent-bg);border-color:var(--accent)}
|
||
.ns-arrow{font-size:8px;transition:transform .15s}
|
||
.ns-dot{width:6px;height:6px;border-radius:50%;background:var(--accent);flex-shrink:0}
|
||
.ns-body{border:1px solid var(--accent);border-top:none;border-radius:0 0 5px 5px;background:var(--surface);padding:10px;display:flex;flex-direction:column;gap:10px}
|
||
.ns-section{}
|
||
.ns-section-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:6px;display:flex;align-items:center;justify-content:space-between}
|
||
.ns-deco-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px}
|
||
.ns-field{display:flex;flex-direction:column;gap:2px}
|
||
.ns-field-lbl{font-size:10px;color:var(--muted)}
|
||
.ns-field-inp{font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt)}
|
||
.ns-field-inp:focus{outline:none;border-color:var(--border-focus);background:#fff}
|
||
.ns-attr-row{display:flex;gap:4px;margin-bottom:4px;align-items:center}
|
||
.ns-attr-key{width:90px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt)}
|
||
.ns-attr-val{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt)}
|
||
.ns-attr-key:focus,.ns-attr-val:focus{outline:none;border-color:var(--border-focus);background:#fff}
|
||
.ns-empty{font-size:11px;color:var(--muted);font-style:italic;padding:2px 0}
|
||
.ns-value-row{display:flex;gap:6px;align-items:center}
|
||
.ns-dtype{flex:0 0 auto;min-width:130px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt)}
|
||
.ns-dtype:focus{outline:none;border-color:var(--border-focus)}
|
||
.ns-value-inp{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt)}
|
||
.ns-value-inp:focus{outline:none;border-color:var(--border-focus);background:#fff}
|
||
.ns-add-btn{font-size:10px;padding:1px 7px}
|
||
|
||
/* ── 이름 목록 그룹 ───────────────────────── */
|
||
.grp{border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:7px;background:var(--surface-alt)}
|
||
.grp:last-child{margin-bottom:0}
|
||
.grp-head{display:flex;align-items:center;gap:6px;margin-bottom:6px}
|
||
.grp-type-badge{font-size:10px;font-weight:700;color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:4px;padding:1px 6px;text-transform:uppercase;letter-spacing:.04em}
|
||
.grp-cat-badge{font-size:11px;color:var(--accent);background:var(--accent-bg);border-radius:4px;padding:1px 7px;font-weight:600;white-space:nowrap;max-width:100px;overflow:hidden;text-overflow:ellipsis}
|
||
.grp-names{width:100%;min-height:44px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:5px 7px;resize:vertical;font-family:inherit;background:var(--surface);line-height:1.55}
|
||
.grp-names:focus{outline:none;border-color:var(--border-focus)}
|
||
.grp-opts{display:flex;flex-direction:column;gap:5px;margin-top:6px;padding-top:6px;border-top:1px dashed var(--border)}
|
||
.opt-row{display:flex;align-items:center;gap:6px}
|
||
.opt-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);width:40px;flex-shrink:0}
|
||
.sep-chips{display:flex;gap:3px;flex-wrap:wrap}
|
||
.sep-chip{font-size:11px;padding:2px 8px;border-radius:4px;border:1px solid var(--border);background:var(--surface);color:var(--muted);cursor:pointer;font-weight:500;transition:all .1s;white-space:nowrap}
|
||
.sep-chip:hover{border-color:var(--accent);color:var(--accent)}
|
||
.sep-chip.on{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||
.sep-custom{width:52px;font-size:11px;border:1px solid var(--border);border-radius:4px;padding:2px 5px;background:var(--surface);text-align:center}
|
||
.sep-custom:focus{outline:none;border-color:var(--border-focus)}
|
||
.npl-input{width:46px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 5px;text-align:center;background:var(--surface)}
|
||
.npl-input:focus{outline:none;border-color:var(--border-focus)}
|
||
.npl-input:disabled{background:var(--surface-alt);color:var(--muted)}
|
||
|
||
/* ── 하위노드 영역 ────────────────────────── */
|
||
.children-area{display:flex;flex-direction:column;gap:6px}
|
||
.children-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
|
||
.children-list{display:flex;flex-direction:column;gap:8px;padding-left:12px;border-left:2px solid var(--border)}
|
||
.btn-add-child{align-self:flex-start;display:flex;align-items:center;gap:5px;font-size:11px;padding:4px 10px;background:var(--green-bg);color:var(--green);border:1px dashed var(--green);border-radius:6px;cursor:pointer;font-weight:600;transition:all .15s}
|
||
.btn-add-child:hover{background:var(--green);color:#fff;border-style:solid}
|
||
|
||
/* ── 미리보기 패널 ────────────────────────── */
|
||
.preview-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--dk)}
|
||
.preview-head{display:flex;align-items:center;gap:7px;padding:9px 14px;background:var(--dk2);border-bottom:1px solid var(--dk3);flex-shrink:0}
|
||
.tabs{display:flex;gap:2px}
|
||
.tab{font-size:12px;padding:4px 11px;border-radius:5px;cursor:pointer;color:var(--dt3);border:none;background:transparent;font-weight:500;transition:all .15s}
|
||
.tab.active{background:var(--dk3);color:var(--dt)}
|
||
.p-spacer{flex:1}
|
||
.copy-btn{display:flex;align-items:center;gap:5px;font-size:12px;padding:5px 11px;background:var(--dk3);color:var(--dt2);border:1px solid var(--dk4);border-radius:6px;cursor:pointer;transition:all .15s;font-weight:500}
|
||
.copy-btn:hover{background:var(--dk4)}
|
||
.copy-btn.ok{background:#1a3a2a;color:#a6e3a1;border-color:#a6e3a1}
|
||
.preview-body{flex:1;overflow-y:auto;padding:20px 22px}
|
||
.preview-body::-webkit-scrollbar{width:5px}
|
||
.preview-body::-webkit-scrollbar-track{background:var(--dk)}
|
||
.preview-body::-webkit-scrollbar-thumb{background:var(--dk4);border-radius:3px}
|
||
.preview-text{font-family:'Cascadia Code','Fira Code','Consolas',monospace;font-size:13px;line-height:1.75;color:var(--dt);white-space:pre-wrap;word-break:break-word}
|
||
.preview-text.dim{color:var(--dt3);font-style:italic}
|
||
.j-key{color:#89b4fa}.j-str{color:#a6e3a1}.j-num{color:#fab387}.j-bool{color:#f38ba8}.j-null{color:#f38ba8;font-style:italic}
|
||
|
||
/* ── 모달 ─────────────────────────────────── */
|
||
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;z-index:200}
|
||
.overlay.open{display:flex}
|
||
.modal{background:var(--surface);border-radius:12px;padding:20px;width:500px;max-width:92vw;box-shadow:0 20px 60px rgba(0,0,0,.25)}
|
||
.modal h2{font-size:15px;font-weight:700;margin-bottom:6px}
|
||
.modal p{font-size:12px;color:var(--muted);margin-bottom:10px}
|
||
.modal-field{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
||
.modal-field label{font-size:12px;color:var(--muted);width:80px;flex-shrink:0}
|
||
.modal-field input{font-size:13px;border:1.5px solid var(--border);border-radius:6px;padding:4px 8px;flex:1;outline:none}
|
||
.modal-field input:focus{border-color:var(--border-focus)}
|
||
.modal textarea{width:100%;height:180px;font-family:monospace;font-size:12px;border:1.5px solid var(--border);border-radius:6px;padding:9px;resize:vertical;line-height:1.6}
|
||
.modal textarea:focus{outline:none;border-color:var(--border-focus)}
|
||
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
|
||
.mBtn{font-size:13px;padding:7px 16px;border-radius:6px;border:1px solid;cursor:pointer;font-weight:600}
|
||
.mBtn.ok{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||
.mBtn.ok:hover{background:#3a5ce0}
|
||
.mBtn.cancel{background:var(--surface);color:var(--muted);border-color:var(--border)}
|
||
.mBtn.cancel:hover{background:var(--surface-alt)}
|
||
.sep{height:1px;background:var(--border);margin:10px 0}
|
||
input,textarea,button,select{font-family:inherit}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="app-header">
|
||
<span style="font-size:18px">🏗️</span>
|
||
<h1>CTStruct 양식 빌더</h1>
|
||
<span class="badge">v0.3</span>
|
||
<div class="hdr-spacer"></div>
|
||
<button class="hdr-btn" onclick="openImport()">📥 불러오기</button>
|
||
<button class="hdr-btn" onclick="newDoc()">🗒 새 문서</button>
|
||
</header>
|
||
|
||
<div class="app-body">
|
||
|
||
<div class="builder-panel">
|
||
<div class="panel-label">✏️ 편집기</div>
|
||
<div class="builder-scroll">
|
||
|
||
<!-- 문서 루트 노드 -->
|
||
<div class="root-node">
|
||
<span class="root-lbl">문서</span>
|
||
<input class="node-mark-inp" id="rootMark" title="제목 구분자"
|
||
oninput="S.titleMark=this.value;updatePreview();save()">
|
||
<input class="root-title-inp" id="docTitle" placeholder="제목 없음"
|
||
oninput="S.title=this.value;updatePreview();save()">
|
||
</div>
|
||
|
||
<button class="btn-add-sec" onclick="addSection(null)">+ 섹션 추가</button>
|
||
<div class="sec-list" id="secList"></div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-panel">
|
||
<div class="preview-head">
|
||
<div class="tabs">
|
||
<button class="tab active" id="tab-text" onclick="setTab('text')">텍스트</button>
|
||
<button class="tab" id="tab-json" onclick="setTab('json')">JSON slim</button>
|
||
<button class="tab" id="tab-json-full" onclick="setTab('json-full')">JSON full</button>
|
||
</div>
|
||
<div class="p-spacer"></div>
|
||
<button class="copy-btn" id="copyBtn" onclick="copyOutput()">📋 복사</button>
|
||
</div>
|
||
<div class="preview-body">
|
||
<pre class="preview-text" id="previewOut"></pre>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="overlay" id="importModal">
|
||
<div class="modal">
|
||
<h2>📥 텍스트 불러오기</h2>
|
||
<p>기존 양식 텍스트를 붙여넣으면 편집기로 불러옵니다.</p>
|
||
<div class="modal-field">
|
||
<label>제목 구분자</label>
|
||
<input id="importTitleMark" style="width:60px;flex:none" value="✅">
|
||
</div>
|
||
<div class="modal-field">
|
||
<label>섹션 구분자들</label>
|
||
<input id="importMarks" placeholder="🔹,🔸 (쉼표 구분, 순서대로 L1·L2…)">
|
||
</div>
|
||
<div class="sep"></div>
|
||
<textarea id="importTxt" placeholder="✅예시 양식 🔹1번 항목 홍길동 홍길순 🔹2번 항목 🔸소항목 (건강) 홍길찬"></textarea>
|
||
<div class="modal-actions">
|
||
<button class="mBtn cancel" onclick="closeImport()">취소</button>
|
||
<button class="mBtn ok" onclick="doImport()">불러오기</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ════════════════════════════════════════════════════════
|
||
// 상수
|
||
// ════════════════════════════════════════════════════════
|
||
const DATA_TYPES = [
|
||
{v:'unset', lbl:'UNSET'},
|
||
{v:'string', lbl:'STRING 문자열'},
|
||
{v:'integer', lbl:'INTEGER 정수'},
|
||
{v:'float', lbl:'FLOAT 실수'},
|
||
{v:'boolean', lbl:'BOOLEAN 참/거짓'},
|
||
{v:'null', lbl:'NULL'},
|
||
{v:'date', lbl:'DATE 날짜'},
|
||
{v:'datetime',lbl:'DATETIME 날짜+시간'},
|
||
{v:'time', lbl:'TIME 시간'},
|
||
{v:'list', lbl:'LIST 목록'},
|
||
{v:'dict', lbl:'DICT 맵'},
|
||
{v:'set', lbl:'SET 집합'},
|
||
{v:'markdown',lbl:'MARKDOWN'},
|
||
{v:'html', lbl:'HTML'},
|
||
{v:'json', lbl:'JSON'},
|
||
{v:'code', lbl:'CODE'},
|
||
{v:'custom', lbl:'CUSTOM'},
|
||
];
|
||
|
||
const SEP_PRESETS = [
|
||
{v:' ', lbl:'공백'},
|
||
{v:', ', lbl:', '},
|
||
{v:' / ', lbl:'/'},
|
||
{v:'\n', lbl:'↵ 줄바꿈'},
|
||
];
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 팩토리
|
||
// ════════════════════════════════════════════════════════
|
||
function uid(){ return Math.random().toString(36).slice(2,9) }
|
||
|
||
function nodeDefaults(){
|
||
return { header:null, footer:null, prefix:null, suffix:null,
|
||
attributes:{}, data_type:'unset', value:null };
|
||
}
|
||
|
||
function mkSec(mark='', content='', lv=1){
|
||
return { id:uid(), lv, mark, content, ...nodeDefaults(), groups:[], children:[] };
|
||
}
|
||
|
||
function mkGrp(category='', names='', sep=' ', npl=0){
|
||
const attrs = category ? {category} : {};
|
||
return { id:uid(), content:null, ...nodeDefaults(),
|
||
attributes:attrs, names, sep, namesPerLine:npl };
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 상태
|
||
// ════════════════════════════════════════════════════════
|
||
let S = {
|
||
titleMark: '✅',
|
||
title: '예시 양식',
|
||
sections: [
|
||
{ ...mkSec('🔹','1번 항목',1), groups:[mkGrp('','홍길동 홍길순 홍길영 홍길준\n홍길호 홍길윤')] },
|
||
{ ...mkSec('🔹','2번 항목',1), children:[
|
||
{ ...mkSec('🔸','2-1 항목',2), groups:[mkGrp('건강','홍길찬 홍길만')] },
|
||
{ ...mkSec('🔸','2-2 항목',2), groups:[mkGrp('직장','홍길주')] }
|
||
]}
|
||
]
|
||
};
|
||
|
||
let currentTab = 'text';
|
||
const openSettings = new Set();
|
||
|
||
// ── 영속성 ────────────────────────────────────────────
|
||
function save(){ try{ localStorage.setItem('ctstruct-builder', JSON.stringify(S)) }catch(_){} }
|
||
|
||
function load(){
|
||
try{
|
||
const raw = localStorage.getItem('ctstruct-builder');
|
||
if(raw){ S = JSON.parse(raw); migrateState(S); }
|
||
}catch(_){}
|
||
}
|
||
|
||
function migrateState(s){
|
||
if(!s.titleMark) s.titleMark = s.cfg?.titleMark ?? '✅';
|
||
const oldMarks = s.cfg?.marks ?? [];
|
||
if(s.cfg) delete s.cfg;
|
||
if(!s.sections) s.sections = [];
|
||
migrateSecList(s.sections, oldMarks);
|
||
}
|
||
|
||
function migrateSecList(secs, oldMarks){
|
||
for(const s of secs){
|
||
if(s.mark === undefined) s.mark = oldMarks[(s.lv??1)-1] ?? '';
|
||
if(s.name !== undefined){ s.content = s.content ?? s.name; delete s.name; }
|
||
if(!s.lv) s.lv = 1;
|
||
if(!s.groups) s.groups = [];
|
||
if(!s.children) s.children = [];
|
||
if(s.header === undefined) s.header = null;
|
||
if(s.footer === undefined) s.footer = null;
|
||
if(s.prefix === undefined) s.prefix = null;
|
||
if(s.suffix === undefined) s.suffix = null;
|
||
if(!s.attributes) s.attributes = {};
|
||
if(!s.data_type) s.data_type = 'unset';
|
||
if(s.value === undefined) s.value = null;
|
||
for(const g of s.groups){
|
||
if(g.cat !== undefined){ g.attributes=g.attributes||{}; if(g.cat) g.attributes.category=g.cat; delete g.cat; }
|
||
if(g.header === undefined) g.header = null;
|
||
if(g.footer === undefined) g.footer = null;
|
||
if(g.prefix === undefined) g.prefix = null;
|
||
if(g.suffix === undefined) g.suffix = null;
|
||
if(!g.attributes) g.attributes = {};
|
||
if(!g.data_type) g.data_type = 'unset';
|
||
if(g.value === undefined) g.value = null;
|
||
if(!g.sep) g.sep = ' ';
|
||
if(g.namesPerLine === undefined) g.namesPerLine = 0;
|
||
}
|
||
migrateSecList(s.children, oldMarks);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 노드 탐색 / 수정
|
||
// ════════════════════════════════════════════════════════
|
||
function findSec(list, id){
|
||
for(const s of list){ if(s.id===id) return s; const f=findSec(s.children,id); if(f) return f; } return null;
|
||
}
|
||
function findGrp(secs, id){
|
||
for(const s of secs){
|
||
for(const g of s.groups){ if(g.id===id) return g; }
|
||
const f=findGrp(s.children,id); if(f) return f;
|
||
}
|
||
return null;
|
||
}
|
||
function findNode(id){ return findSec(S.sections,id)||findGrp(S.sections,id); }
|
||
|
||
function setNodeField(nid, field, val){
|
||
const n=findNode(nid); if(!n) return;
|
||
n[field] = (val==='')?null:val;
|
||
updatePreview(); save();
|
||
}
|
||
|
||
// ── 어트리뷰트 ─────────────────────────────────────────
|
||
function addAttr(nid){
|
||
const n=findNode(nid); if(!n) return;
|
||
const newKey = Object.keys(n.attributes).includes('')? '_new'+Date.now() : '';
|
||
n.attributes[newKey]='';
|
||
renderSections(); updatePreview(); save();
|
||
setTimeout(()=>{
|
||
const rows=document.querySelectorAll(`[data-nid="${nid}"] .ns-attr-key`);
|
||
if(rows.length) rows[rows.length-1].focus();
|
||
},30);
|
||
}
|
||
function removeAttr(nid, key){
|
||
const n=findNode(nid); if(!n) return;
|
||
delete n.attributes[key];
|
||
renderSections(); updatePreview(); save();
|
||
}
|
||
function updateAttrKey(nid, oldKey, newKey){
|
||
const n=findNode(nid); if(!n||oldKey===newKey) return;
|
||
const val=n.attributes[oldKey]; delete n.attributes[oldKey]; n.attributes[newKey]=val;
|
||
updatePreview(); save();
|
||
}
|
||
function updateAttrVal(nid, key, val){
|
||
const n=findNode(nid); if(!n) return;
|
||
n.attributes[key]=val; updatePreview(); save();
|
||
}
|
||
|
||
// ── 노드 설정 패널 토글 ────────────────────────────────
|
||
function toggleSettings(nid){
|
||
openSettings.has(nid)? openSettings.delete(nid): openSettings.add(nid);
|
||
renderSections();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 섹션 CRUD
|
||
// ════════════════════════════════════════════════════════
|
||
function removeSec(list, id){ return list.filter(s=>s.id!==id).map(s=>({...s,children:removeSec(s.children,id)})) }
|
||
|
||
function addSection(parentId){
|
||
const parent = parentId ? findSec(S.sections, parentId) : null;
|
||
const lv = parent ? parent.lv+1 : 1;
|
||
const ns = mkSec('', '', lv);
|
||
if(!parent) S.sections.push(ns);
|
||
else parent.children.push(ns);
|
||
renderSections(); updatePreview(); save();
|
||
setTimeout(()=>{ const el=document.querySelector(`[data-sid="${ns.id}"] .sec-name-input`); if(el) el.focus(); },30);
|
||
}
|
||
function deleteSection(id){ S.sections=removeSec(S.sections,id); renderSections(); updatePreview(); save(); }
|
||
function moveSec(id, dir){
|
||
function move(list){ const i=list.findIndex(s=>s.id===id); if(i<0) return list.some(s=>move(s.children)); const j=i+dir; if(j<0||j>=list.length) return false; [list[i],list[j]]=[list[j],list[i]]; return true; }
|
||
move(S.sections); renderSections(); updatePreview(); save();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 그룹 CRUD
|
||
// ════════════════════════════════════════════════════════
|
||
function addGroup(sid){
|
||
const s=findSec(S.sections,sid); if(!s) return;
|
||
const ng=mkGrp(); s.groups.push(ng);
|
||
renderSections(); updatePreview(); save();
|
||
setTimeout(()=>{ const el=document.querySelector(`[data-gid="${ng.id}"] .grp-names`); if(el) el.focus(); },30);
|
||
}
|
||
function deleteGroup(sid, gid){
|
||
const s=findSec(S.sections,sid); if(!s) return;
|
||
s.groups=s.groups.filter(g=>g.id!==gid);
|
||
renderSections(); updatePreview(); save();
|
||
}
|
||
function moveGroup(sid, gid, dir){
|
||
const s=findSec(S.sections,sid); if(!s) return;
|
||
const i=s.groups.findIndex(g=>g.id===gid); const j=i+dir;
|
||
if(j<0||j>=s.groups.length) return;
|
||
[s.groups[i],s.groups[j]]=[s.groups[j],s.groups[i]];
|
||
renderSections(); updatePreview(); save();
|
||
}
|
||
function updateGroup(sid, gid, field, val){
|
||
const s=findSec(S.sections,sid); if(!s) return;
|
||
const g=s.groups.find(g=>g.id===gid); if(!g) return;
|
||
g[field]=val; updatePreview(); save();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 빌더 렌더링
|
||
// ════════════════════════════════════════════════════════
|
||
function esc(s){ return String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') }
|
||
|
||
function hasNodeExtras(n){
|
||
return n.header||n.footer||n.prefix||n.suffix||n.value!==null||n.data_type!=='unset'||
|
||
Object.keys(n.attributes||{}).some(k=>k!=='level'&&k!=='category');
|
||
}
|
||
|
||
function renderGroups(sid, sec){
|
||
if(!sec.groups||!sec.groups.length) return '';
|
||
return sec.groups.map(g=>{
|
||
const sep=g.sep??' ', npl=g.namesPerLine??0, isNl=sep==='\n';
|
||
const catBadge=(g.attributes?.category)
|
||
?`<span class="grp-cat-badge">(${esc(g.attributes.category)})</span>`:'';
|
||
const sepChips=SEP_PRESETS.map(p=>`
|
||
<span class="sep-chip${sep===p.v?' on':''}"
|
||
onclick="updateGroup('${sid}','${g.id}','sep',${JSON.stringify(p.v)});renderSections();updatePreview();save()"
|
||
>${p.lbl}</span>`).join('');
|
||
const customVal=SEP_PRESETS.find(p=>p.v===sep)?'':esc(sep);
|
||
const grpOpen=openSettings.has(g.id);
|
||
const grpAttrs=Object.entries(g.attributes||{}).filter(([k])=>k!=='category');
|
||
const grpHasDot=hasNodeExtras(g);
|
||
const dtOpts=DATA_TYPES.map(d=>`<option value="${d.v}" ${g.data_type===d.v?'selected':''}>${d.lbl}</option>`).join('');
|
||
const grpAttrsHtml=grpAttrs.length?grpAttrs.map(([k,v])=>`
|
||
<div class="ns-attr-row">
|
||
<input class="ns-attr-key" placeholder="키" value="${esc(k)}"
|
||
onblur="updateAttrKey('${g.id}','${esc(k)}',this.value)">
|
||
<input class="ns-attr-val" placeholder="값" value="${esc(String(v??''))}"
|
||
oninput="updateAttrVal('${g.id}','${esc(k)}',this.value)">
|
||
<button class="iBtn" onclick="removeAttr('${g.id}','${esc(k)}')">✕</button>
|
||
</div>`).join(''):`<div class="ns-empty">없음</div>`;
|
||
const grpDecoHtml=[{f:'header',lbl:'머리글'},{f:'footer',lbl:'꼬리글'},{f:'prefix',lbl:'접두사'},{f:'suffix',lbl:'접미사'}]
|
||
.map(({f,lbl})=>`
|
||
<div class="ns-field">
|
||
<span class="ns-field-lbl">${lbl}</span>
|
||
<input class="ns-field-inp" placeholder="${f}" value="${esc(g[f]??'')}"
|
||
oninput="setNodeField('${g.id}','${f}',this.value)">
|
||
</div>`).join('');
|
||
return `
|
||
<div class="grp" data-gid="${g.id}">
|
||
<div class="grp-head">
|
||
<span class="grp-type-badge">LIST</span>
|
||
${catBadge}
|
||
<span style="flex:1"></span>
|
||
<button class="iBtn up" onclick="moveGroup('${sid}','${g.id}',-1)" title="위로">↑</button>
|
||
<button class="iBtn dn" onclick="moveGroup('${sid}','${g.id}',1)" title="아래로">↓</button>
|
||
<button class="iBtn" onclick="deleteGroup('${sid}','${g.id}')" title="삭제">✕</button>
|
||
</div>
|
||
<textarea class="grp-names"
|
||
placeholder="이름 입력 (공백·줄바꿈·쉼표로 구분)"
|
||
oninput="updateGroup('${sid}','${g.id}','names',this.value)">${esc(g.names)}</textarea>
|
||
<div class="node-settings" data-nid="${g.id}" style="margin-top:6px">
|
||
<div class="ns-toggle${grpOpen?' open':''}" onclick="toggleSettings('${g.id}')">
|
||
<span class="ns-arrow">${grpOpen?'▼':'▶'}</span>
|
||
노드 설정
|
||
${grpHasDot?'<span class="ns-dot"></span>':''}
|
||
</div>
|
||
${grpOpen?`
|
||
<div class="ns-body">
|
||
<div class="ns-section">
|
||
<div class="ns-section-lbl">텍스트 장식</div>
|
||
<div class="ns-deco-grid">${grpDecoHtml}</div>
|
||
</div>
|
||
<div class="ns-section">
|
||
<div class="ns-section-lbl">
|
||
어트리뷰트
|
||
<button class="sBtn blue ns-add-btn" onclick="addAttr('${g.id}')">+ 추가</button>
|
||
</div>
|
||
${grpAttrsHtml}
|
||
</div>
|
||
<div class="ns-section">
|
||
<div class="ns-section-lbl">값(value)</div>
|
||
<div class="ns-value-row">
|
||
<select class="ns-dtype" onchange="setNodeField('${g.id}','data_type',this.value)">${dtOpts}</select>
|
||
<input class="ns-value-inp" placeholder="값 입력"
|
||
value="${esc(String(g.value??''))}"
|
||
oninput="setNodeField('${g.id}','value',this.value)">
|
||
</div>
|
||
</div>
|
||
</div>`:''}
|
||
</div>
|
||
<div class="grp-opts">
|
||
<div class="opt-row">
|
||
<span class="opt-lbl">구분자</span>
|
||
<div class="sep-chips">${sepChips}</div>
|
||
<input class="sep-custom" placeholder="직접" value="${customVal}"
|
||
oninput="updateGroup('${sid}','${g.id}','sep',this.value||' ');updatePreview();save()">
|
||
</div>
|
||
<div class="opt-row">
|
||
<span class="opt-lbl">한 줄</span>
|
||
<input class="npl-input" type="number" min="0" step="1"
|
||
value="${npl}" ${isNl?'disabled':''}
|
||
oninput="updateGroup('${sid}','${g.id}','namesPerLine',Math.max(0,+this.value||0));updatePreview();save()">
|
||
<span style="font-size:11px;color:var(--muted)">${isNl?'(줄바꿈 모드)':'개 (0=제한없음)'}</span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderNodeSettings(nid, node){
|
||
const open=openSettings.has(nid);
|
||
const isSection=Array.isArray(node.groups);
|
||
const attrs=Object.entries(node.attributes||{}).filter(([k])=>k!=='level'&&k!=='category');
|
||
const hasDot=hasNodeExtras(node)||(isSection&&node.groups.length>0);
|
||
|
||
const decoHtml=[
|
||
{f:'header',lbl:'머리글(header)'},
|
||
{f:'footer',lbl:'꼬리글(footer)'},
|
||
{f:'prefix',lbl:'접두사(prefix)'},
|
||
{f:'suffix',lbl:'접미사(suffix)'},
|
||
].map(({f,lbl})=>`
|
||
<div class="ns-field">
|
||
<span class="ns-field-lbl">${lbl}</span>
|
||
<input class="ns-field-inp" placeholder="${f}"
|
||
value="${esc(node[f]??'')}"
|
||
oninput="setNodeField('${nid}','${f}',this.value)">
|
||
</div>`).join('');
|
||
|
||
const attrsHtml=attrs.length?attrs.map(([k,v])=>`
|
||
<div class="ns-attr-row">
|
||
<input class="ns-attr-key" placeholder="키" value="${esc(k)}"
|
||
onblur="updateAttrKey('${nid}','${esc(k)}',this.value)">
|
||
<input class="ns-attr-val" placeholder="값" value="${esc(String(v??''))}"
|
||
oninput="updateAttrVal('${nid}','${esc(k)}',this.value)">
|
||
<button class="iBtn" onclick="removeAttr('${nid}','${esc(k)}')">✕</button>
|
||
</div>`).join(''):`<div class="ns-empty">없음</div>`;
|
||
|
||
const dtOpts=DATA_TYPES.map(d=>
|
||
`<option value="${d.v}" ${node.data_type===d.v?'selected':''}>${d.lbl}</option>`).join('');
|
||
|
||
return `
|
||
<div class="node-settings" data-nid="${nid}">
|
||
<div class="ns-toggle${open?' open':''}" onclick="toggleSettings('${nid}')">
|
||
<span class="ns-arrow">${open?'▼':'▶'}</span>
|
||
노드 설정
|
||
${hasDot?'<span class="ns-dot"></span>':''}
|
||
</div>
|
||
${open?`
|
||
<div class="ns-body">
|
||
${isSection?`
|
||
<div class="ns-section">
|
||
<div class="ns-section-lbl">
|
||
이름 목록
|
||
<button class="sBtn blue ns-add-btn" onclick="addGroup('${nid}')">+ 추가</button>
|
||
</div>
|
||
${renderGroups(nid,node)||'<div class="ns-empty">없음</div>'}
|
||
</div>`:''}
|
||
<div class="ns-section">
|
||
<div class="ns-section-lbl">텍스트 장식</div>
|
||
<div class="ns-deco-grid">${decoHtml}</div>
|
||
</div>
|
||
<div class="ns-section">
|
||
<div class="ns-section-lbl">
|
||
어트리뷰트
|
||
<button class="sBtn blue ns-add-btn" onclick="addAttr('${nid}')">+ 추가</button>
|
||
</div>
|
||
${attrsHtml}
|
||
</div>
|
||
<div class="ns-section">
|
||
<div class="ns-section-lbl">값(value)</div>
|
||
<div class="ns-value-row">
|
||
<select class="ns-dtype"
|
||
onchange="setNodeField('${nid}','data_type',this.value)">${dtOpts}</select>
|
||
<input class="ns-value-inp" placeholder="값 입력"
|
||
value="${esc(String(node.value??''))}"
|
||
oninput="setNodeField('${nid}','value',this.value)">
|
||
</div>
|
||
</div>
|
||
</div>`:''}
|
||
</div>`;
|
||
}
|
||
|
||
function renderSec(sec){
|
||
const childrenHtml=sec.children.map(c=>renderSec(c)).join('');
|
||
const hasChildren=sec.children.length>0;
|
||
return `
|
||
<div class="sec-card" data-sid="${sec.id}">
|
||
<div class="sec-head">
|
||
<input class="node-mark-inp" title="구분자" value="${esc(sec.mark??'')}"
|
||
oninput="setNodeField('${sec.id}','mark',this.value)">
|
||
<input class="sec-name-input" placeholder="섹션 이름"
|
||
value="${esc(sec.content??'')}"
|
||
oninput="setNodeField('${sec.id}','content',this.value)">
|
||
<button class="iBtn up" onclick="moveSec('${sec.id}',-1)" title="위로">↑</button>
|
||
<button class="iBtn dn" onclick="moveSec('${sec.id}',1)" title="아래로">↓</button>
|
||
<button class="iBtn" onclick="deleteSection('${sec.id}')" title="삭제">✕</button>
|
||
</div>
|
||
<div class="sec-body">
|
||
${renderNodeSettings(sec.id, sec)}
|
||
<div class="children-area">
|
||
${hasChildren?`
|
||
<div class="children-lbl">하위노드</div>
|
||
<div class="children-list">${childrenHtml}</div>`:''}
|
||
<button class="btn-add-child" onclick="addSection('${sec.id}')">+ 소항목</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderSections(){
|
||
document.getElementById('rootMark').value = S.titleMark;
|
||
document.getElementById('docTitle').value = S.title;
|
||
document.getElementById('secList').innerHTML = S.sections.map(s=>renderSec(s)).join('');
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 텍스트 변환
|
||
// ════════════════════════════════════════════════════════
|
||
function parseNames(raw){ return raw.trim().split(/[\s,\n]+/).filter(n=>n.length>0) }
|
||
|
||
function toText(){
|
||
const lines=[];
|
||
const title=S.title.trim();
|
||
if(title){ lines.push(`${S.titleMark}${title}`); lines.push(''); }
|
||
|
||
function renderSec(sec){
|
||
lines.push(`${sec.mark??''}${sec.content||''}`);
|
||
for(const g of sec.groups){
|
||
const ns=parseNames(g.names); if(!ns.length) continue;
|
||
const cat=(g.attributes?.category??'').trim();
|
||
const pre=cat?`(${cat}) `:'';
|
||
const sep=g.sep??' ', npl=g.namesPerLine??0;
|
||
if(sep==='\n'){
|
||
ns.forEach((n,i)=>lines.push((i===0?pre:'')+n));
|
||
} else if(npl>0){
|
||
for(let i=0;i<ns.length;i+=npl)
|
||
lines.push((i===0?pre:'')+ns.slice(i,i+npl).join(sep));
|
||
} else {
|
||
lines.push(pre+ns.join(sep));
|
||
}
|
||
lines.push('');
|
||
}
|
||
for(const c of sec.children) renderSec(c);
|
||
}
|
||
for(const s of S.sections) renderSec(s);
|
||
return lines.join('\n').replace(/\n{3,}/g,'\n\n').trimEnd()+'\n';
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// JSON 변환
|
||
// ════════════════════════════════════════════════════════
|
||
function stripNulls(obj){
|
||
if(Array.isArray(obj)) return obj.map(stripNulls);
|
||
if(obj&&typeof obj==='object'){
|
||
const r={};
|
||
for(const [k,v] of Object.entries(obj)){
|
||
if(v===null) continue;
|
||
if(k==='data_type'&&v==='unset') continue;
|
||
if(k==='attributes'&&typeof v==='object'&&!Object.keys(v).length) continue;
|
||
r[k]=stripNulls(v);
|
||
}
|
||
return r;
|
||
}
|
||
return obj;
|
||
}
|
||
|
||
function buildNode(o){
|
||
return { content:null,header:null,footer:null,prefix:null,suffix:null,
|
||
attributes:{},data_type:'unset',value:null,children:[], ...o };
|
||
}
|
||
|
||
function toJSON(slim=false){
|
||
function serGrp(g){
|
||
const ns=parseNames(g.names);
|
||
return buildNode({
|
||
id:g.id, type:'LIST',
|
||
content:g.content, header:g.header, footer:g.footer,
|
||
prefix:g.prefix, suffix:g.suffix,
|
||
attributes:{...g.attributes},
|
||
data_type:g.data_type, value:g.value,
|
||
children:ns.map(n=>buildNode({id:uid(),type:'LIST_ITEM',content:n}))
|
||
});
|
||
}
|
||
function serSec(sec){
|
||
return buildNode({
|
||
id:sec.id, type:'SECTION',
|
||
content:sec.content, header:sec.header, footer:sec.footer,
|
||
prefix:sec.prefix, suffix:sec.suffix,
|
||
attributes:{mark:sec.mark, level:sec.lv, ...sec.attributes},
|
||
data_type:sec.data_type, value:sec.value,
|
||
children:[...sec.groups.map(serGrp), ...sec.children.map(serSec)]
|
||
});
|
||
}
|
||
const root=buildNode({
|
||
id:'root', type:'ROOT',
|
||
attributes:S.title.trim()?{title:S.title.trim(),mark:S.titleMark}:{mark:S.titleMark},
|
||
children:S.sections.map(serSec)
|
||
});
|
||
return JSON.stringify(slim?stripNulls(root):root, null, 2);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// JSON 구문 강조
|
||
// ════════════════════════════════════════════════════════
|
||
function highlight(s){
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/("(?:[^"\\]|\\.)*")(\s*:)/g,'<span class="j-key">$1</span>$2')
|
||
.replace(/:\s*("(?:[^"\\]|\\.)*")/g,': <span class="j-str">$1</span>')
|
||
.replace(/:\s*(-?\d+\.?\d*)/g,': <span class="j-num">$1</span>')
|
||
.replace(/:\s*(true|false)/g,': <span class="j-bool">$1</span>')
|
||
.replace(/:\s*(null)/g,': <span class="j-null">$1</span>');
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 미리보기
|
||
// ════════════════════════════════════════════════════════
|
||
function setTab(t){
|
||
currentTab=t;
|
||
document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));
|
||
document.getElementById('tab-'+t).classList.add('active');
|
||
updatePreview();
|
||
}
|
||
|
||
function updatePreview(){
|
||
const el=document.getElementById('previewOut');
|
||
if(currentTab==='text'){
|
||
const content=toText();
|
||
if(!content.trim()){ el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; }
|
||
el.className='preview-text'; el.textContent=content;
|
||
} else {
|
||
const content=toJSON(currentTab==='json');
|
||
el.className='preview-text'; el.innerHTML=highlight(content);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 복사
|
||
// ════════════════════════════════════════════════════════
|
||
function copyOutput(){
|
||
const content=currentTab==='text'?toText():toJSON(currentTab==='json');
|
||
navigator.clipboard.writeText(content).then(()=>{
|
||
const btn=document.getElementById('copyBtn');
|
||
btn.textContent='✅ 복사됨'; btn.classList.add('ok');
|
||
setTimeout(()=>{ btn.innerHTML='📋 복사'; btn.classList.remove('ok'); },2000);
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 불러오기
|
||
// ════════════════════════════════════════════════════════
|
||
function openImport(){
|
||
document.getElementById('importTitleMark').value=S.titleMark;
|
||
const marks=[];
|
||
function collect(secs){ for(const s of secs){ if(s.mark&&!marks.includes(s.mark)) marks.push(s.mark); collect(s.children||[]); } }
|
||
collect(S.sections);
|
||
document.getElementById('importMarks').value=marks.join(',')||'🔹,🔸';
|
||
document.getElementById('importModal').classList.add('open');
|
||
}
|
||
function closeImport(){ document.getElementById('importModal').classList.remove('open') }
|
||
|
||
function doImport(){
|
||
const raw=document.getElementById('importTxt').value.trim(); if(!raw) return;
|
||
const titleMark=document.getElementById('importTitleMark').value||'✅';
|
||
const marks=document.getElementById('importMarks').value.split(',').map(m=>m.trim()).filter(Boolean);
|
||
const sortedMarks=marks.map((m,i)=>({m,lv:i+1})).sort((a,b)=>b.m.length-a.m.length);
|
||
const reCat=/^\(([^)]+)\)\s*(.*)/;
|
||
let title=''; const sections=[]; const secStack=[]; let pend=null;
|
||
|
||
function flush(){
|
||
if(!pend||!pend.names.length){ pend=null; return; }
|
||
const parent=pend.parentId?findSec(sections,pend.parentId):null;
|
||
const grp=mkGrp(pend.cat||'',pend.names.join(' '));
|
||
if(parent) parent.groups.push(grp); pend=null;
|
||
}
|
||
function topId(){ return secStack.length?secStack[secStack.length-1].id:null }
|
||
|
||
for(const rawLine of raw.split('\n')){
|
||
const line=rawLine.trim(); if(!line){ flush(); continue; }
|
||
if(line.startsWith(titleMark)){ flush(); title=line.slice(titleMark.length).trim(); continue; }
|
||
let matched=false;
|
||
for(const {m,lv} of sortedMarks){
|
||
if(line.startsWith(m)){
|
||
flush();
|
||
const sec=mkSec(m, line.slice(m.length).trim(), lv);
|
||
while(secStack.length){ const top=findSec(sections,secStack[secStack.length-1].id); if(top&&top.lv>=lv) secStack.pop(); else break; }
|
||
if(!secStack.length) sections.push(sec);
|
||
else{ const p=findSec(sections,secStack[secStack.length-1].id); if(p) p.children.push(sec); }
|
||
secStack.push({id:sec.id,lv}); matched=true; break;
|
||
}
|
||
}
|
||
if(matched) continue;
|
||
const cm=reCat.exec(line);
|
||
if(cm){ flush(); pend={parentId:topId(),cat:cm[1].trim(),names:[]}; const rest=cm[2].trim(); if(rest) pend.names.push(...rest.split(/[\s,]+/).filter(Boolean)); continue; }
|
||
if(!pend) pend={parentId:topId(),cat:'',names:[]};
|
||
pend.names.push(...line.split(/[\s,]+/).filter(Boolean));
|
||
}
|
||
flush();
|
||
S.titleMark=titleMark; S.title=title; S.sections=sections;
|
||
renderSections(); updatePreview(); save(); closeImport();
|
||
document.getElementById('importTxt').value='';
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 새 문서
|
||
// ════════════════════════════════════════════════════════
|
||
function newDoc(){
|
||
if(!confirm('현재 내용을 모두 지우고 새 문서를 시작할까요?')) return;
|
||
S.title=''; S.sections=[]; openSettings.clear();
|
||
renderSections(); updatePreview(); save();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 초기화
|
||
// ════════════════════════════════════════════════════════
|
||
load();
|
||
renderSections();
|
||
updatePreview();
|
||
document.getElementById('importModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeImport(); });
|
||
document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeImport(); });
|
||
</script>
|
||
</body>
|
||
</html>
|