refactor(ui): per-node delimiter, node settings panel consolidation

각 노드가 독립적인 구분자를 가지도록 전면 개편. 전역 구분자 설정 카드 제거.
노드 카드 구조: 구분자·제목·노드설정·하위노드. 이름 목록(LIST 그룹)을
노드설정 패널 안으로 통합. 불러오기 모달에 구분자 직접 입력 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gm
2026-06-02 23:21:16 +09:00
parent 3940d0c0ea
commit 33a1296e8b

View File

@@ -13,7 +13,6 @@
--accent:#4f6ef7;--accent-bg:#eef0ff; --accent:#4f6ef7;--accent-bg:#eef0ff;
--red:#ef4444;--red-bg:#fee2e2; --red:#ef4444;--red-bg:#fee2e2;
--green:#16a34a;--green-bg:#dcfce7; --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); --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; --dk:#1e1e2e;--dk2:#181825;--dk3:#313244;--dk4:#45475a;
--dt:#cdd6f4;--dt2:#bac2de;--dt3:#6c7086; --dt:#cdd6f4;--dt2:#bac2de;--dt3:#6c7086;
@@ -36,29 +35,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
.builder-scroll::-webkit-scrollbar{width:5px} .builder-scroll::-webkit-scrollbar{width:5px}
.builder-scroll::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px} .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} .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}
.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} .root-node:focus-within{border-color:var(--border-focus);background:var(--surface)}
.config-row{display:flex;align-items:center;gap:8px;margin-bottom:8px} .root-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex-shrink:0}
.config-row:last-child{margin-bottom:0} .root-title-inp{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent}
.config-lbl{font-size:12px;color:var(--muted);width:48px;flex-shrink:0} .root-title-inp::placeholder{color:var(--muted);font-weight:400}
.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} .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}
.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} .node-mark-inp:focus{border-color:var(--border-focus)}
.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{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}
@@ -69,53 +55,18 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
.sBtn{font-size:11px;padding:3px 9px;border-radius:4px;border:1px solid;cursor:pointer;font-weight:600;transition:all .12s} .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{background:var(--accent-bg);color:var(--accent);border-color:var(--accent)}
.sBtn.blue:hover{background:var(--accent);color:#fff} .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-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{border:1.5px solid var(--border);border-radius:var(--r);background:var(--surface);box-shadow:var(--sh)}
.sec-card[data-level="1"]{border-left:3px solid var(--l1)} .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-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{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-name-input::placeholder{color:var(--muted);font-weight:400}
.sec-body{padding:9px} .sec-body{padding:8px 9px 9px;display:flex;flex-direction:column;gap:8px}
.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} .node-settings{}
.ns-toggle{ .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}
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: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-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-arrow{font-size:8px;transition:transform .15s}
@@ -140,6 +91,34 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
.ns-value-inp:focus{outline:none;border-color:var(--border-focus);background:#fff} .ns-value-inp:focus{outline:none;border-color:var(--border-focus);background:#fff}
.ns-add-btn{font-size:10px;padding:1px 7px} .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-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} .preview-head{display:flex;align-items:center;gap:7px;padding:9px 14px;background:var(--dk2);border-bottom:1px solid var(--dk3);flex-shrink:0}
@@ -158,13 +137,17 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
.preview-text.dim{color:var(--dt3);font-style:italic} .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} .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{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} .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{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 h2{font-size:15px;font-weight:700;margin-bottom:6px}
.modal p{font-size:12px;color:var(--muted);margin-bottom:10px} .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-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 textarea:focus{outline:none;border-color:var(--border-focus)}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px} .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{font-size:13px;padding:7px 16px;border-radius:6px;border:1px solid;cursor:pointer;font-weight:600}
@@ -172,7 +155,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
.mBtn.ok:hover{background:#3a5ce0} .mBtn.ok:hover{background:#3a5ce0}
.mBtn.cancel{background:var(--surface);color:var(--muted);border-color:var(--border)} .mBtn.cancel{background:var(--surface);color:var(--muted);border-color:var(--border)}
.mBtn.cancel:hover{background:var(--surface-alt)} .mBtn.cancel:hover{background:var(--surface-alt)}
.sep{height:1px;background:var(--border);margin:12px 0} .sep{height:1px;background:var(--border);margin:10px 0}
input,textarea,button,select{font-family:inherit} input,textarea,button,select{font-family:inherit}
</style> </style>
</head> </head>
@@ -181,7 +164,7 @@ input,textarea,button,select{font-family:inherit}
<header class="app-header"> <header class="app-header">
<span style="font-size:18px">🏗️</span> <span style="font-size:18px">🏗️</span>
<h1>CTStruct 양식 빌더</h1> <h1>CTStruct 양식 빌더</h1>
<span class="badge">v0.2</span> <span class="badge">v0.3</span>
<div class="hdr-spacer"></div> <div class="hdr-spacer"></div>
<button class="hdr-btn" onclick="openImport()">📥 불러오기</button> <button class="hdr-btn" onclick="openImport()">📥 불러오기</button>
<button class="hdr-btn" onclick="newDoc()">🗒 새 문서</button> <button class="hdr-btn" onclick="newDoc()">🗒 새 문서</button>
@@ -193,27 +176,16 @@ input,textarea,button,select{font-family:inherit}
<div class="panel-label">✏️ 편집기</div> <div class="panel-label">✏️ 편집기</div>
<div class="builder-scroll"> <div class="builder-scroll">
<div class="config-card"> <!-- 문서 루트 노드 -->
<div class="config-card-title">⚙️ 구분자 설정</div> <div class="root-node">
<div class="config-row"> <span class="root-lbl">문서</span>
<span class="config-lbl">제목</span> <input class="node-mark-inp" id="rootMark" title="제목 구분자"
<input id="cfgTitle" class="mark-input" value="✅" oninput="syncConfig()"> oninput="S.titleMark=this.value;updatePreview();save()">
</div> <input class="root-title-inp" id="docTitle" placeholder="제목 없음"
<div class="config-row" style="align-items:flex-start"> oninput="S.title=this.value;updatePreview();save()">
<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>
<div class="field-label">문서 제목</div> <button class="btn-add-sec" onclick="addSection(null)"> 섹션 추가</button>
<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 class="sec-list" id="secList"></div>
</div> </div>
@@ -239,7 +211,16 @@ input,textarea,button,select{font-family:inherit}
<div class="overlay" id="importModal"> <div class="overlay" id="importModal">
<div class="modal"> <div class="modal">
<h2>📥 텍스트 불러오기</h2> <h2>📥 텍스트 불러오기</h2>
<p>기존 양식 텍스트를 붙여넣으면 편집기로 불러옵니다. 현재 구분자 설정 기준으로 파싱합니다.</p> <p>기존 양식 텍스트를 붙여넣으면 편집기로 불러옵니다.</p>
<div class="modal-field">
<label>제목 구분자</label>
<input id="importTitleMark" style="width:60px;flex:none" value="✅">
</div>
<div class="modal-field">
<label>섹션 구분자들</label>
<input id="importMarks" placeholder="🔹,🔸 (쉼표 구분, 순서대로 L1·L2…)">
</div>
<div class="sep"></div>
<textarea id="importTxt" placeholder="✅예시 양식&#10;&#10;🔹1번 항목&#10;홍길동 홍길순&#10;&#10;🔹2번 항목&#10;🔸소항목&#10;(건강) 홍길찬"></textarea> <textarea id="importTxt" placeholder="✅예시 양식&#10;&#10;🔹1번 항목&#10;홍길동 홍길순&#10;&#10;🔹2번 항목&#10;🔸소항목&#10;(건강) 홍길찬"></textarea>
<div class="modal-actions"> <div class="modal-actions">
<button class="mBtn cancel" onclick="closeImport()">취소</button> <button class="mBtn cancel" onclick="closeImport()">취소</button>
@@ -284,18 +265,15 @@ const SEP_PRESETS = [
// ════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════
function uid(){ return Math.random().toString(36).slice(2,9) } function uid(){ return Math.random().toString(36).slice(2,9) }
/** CTNode 기본 필드 */
function nodeDefaults(){ function nodeDefaults(){
return { header:null, footer:null, prefix:null, suffix:null, return { header:null, footer:null, prefix:null, suffix:null,
attributes:{}, data_type:'unset', value:null }; attributes:{}, data_type:'unset', value:null };
} }
/** SECTION 노드 */ function mkSec(mark='', content='', lv=1){
function mkSec(lv, content=''){ return { id:uid(), lv, mark, content, ...nodeDefaults(), groups:[], children:[] };
return { id:uid(), lv, content, ...nodeDefaults(), groups:[], children:[] };
} }
/** LIST 노드 (names/sep/namesPerLine 은 표시 설정) */
function mkGrp(category='', names='', sep=' ', npl=0){ function mkGrp(category='', names='', sep=' ', npl=0){
const attrs = category ? {category} : {}; const attrs = category ? {category} : {};
return { id:uid(), content:null, ...nodeDefaults(), return { id:uid(), content:null, ...nodeDefaults(),
@@ -306,19 +284,19 @@ function mkGrp(category='', names='', sep=' ', npl=0){
// 상태 // 상태
// ════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════
let S = { let S = {
cfg: { titleMark:'✅', marks:['🔹','🔸'] }, titleMark: '✅',
title: '예시 양식', title: '예시 양식',
sections: [ sections: [
{ ...mkSec(1,'1번 항목'), groups:[mkGrp('','홍길동 홍길순 홍길영 홍길준\n홍길호 홍길윤')] }, { ...mkSec('🔹','1번 항목',1), groups:[mkGrp('','홍길동 홍길순 홍길영 홍길준\n홍길호 홍길윤')] },
{ ...mkSec(1,'2번 항목'), children:[ { ...mkSec('🔹','2번 항목',1), children:[
{ ...mkSec(2,'2-1 항목'), groups:[mkGrp('건강','홍길찬 홍길만')] }, { ...mkSec('🔸','2-1 항목',2), groups:[mkGrp('건강','홍길찬 홍길만')] },
{ ...mkSec(2,'2-2 항목'), groups:[mkGrp('직장','홍길주')] } { ...mkSec('🔸','2-2 항목',2), groups:[mkGrp('직장','홍길주')] }
]} ]}
] ]
}; };
let currentTab = 'text'; let currentTab = 'text';
const openSettings = new Set(); // 열려 있는 노드 설정 패널 IDs const openSettings = new Set();
// ── 영속성 ──────────────────────────────────────────── // ── 영속성 ────────────────────────────────────────────
function save(){ try{ localStorage.setItem('ctstruct-builder', JSON.stringify(S)) }catch(_){} } function save(){ try{ localStorage.setItem('ctstruct-builder', JSON.stringify(S)) }catch(_){} }
@@ -326,58 +304,46 @@ function save(){ try{ localStorage.setItem('ctstruct-builder', JSON.stringify(S)
function load(){ function load(){
try{ try{
const raw = localStorage.getItem('ctstruct-builder'); const raw = localStorage.getItem('ctstruct-builder');
if(raw){ S = JSON.parse(raw); migrate(S.sections); } if(raw){ S = JSON.parse(raw); migrateState(S); }
}catch(_){} }catch(_){}
} }
/** 구버전(name/cat 필드) → 현재 모델 마이그레이션 */ function migrateState(s){
function migrate(secs){ if(!s.titleMark) s.titleMark = s.cfg?.titleMark ?? '✅';
for(const s of secs){ const oldMarks = s.cfg?.marks ?? [];
if(s.name !== undefined){ s.content = s.content ?? s.name; delete s.name; } if(s.cfg) delete s.cfg;
if(!s.header) s.header = null; if(!s.sections) s.sections = [];
if(!s.footer) s.footer = null; migrateSecList(s.sections, oldMarks);
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 migrateSecList(secs, oldMarks){
// 설정 (구분자) for(const s of secs){
// ════════════════════════════════════════════════════════ if(s.mark === undefined) s.mark = oldMarks[(s.lv??1)-1] ?? '';
function syncConfig(){ if(s.name !== undefined){ s.content = s.content ?? s.name; delete s.name; }
S.cfg.titleMark = document.getElementById('cfgTitle').value || '✅'; if(!s.lv) s.lv = 1;
document.getElementById('titleMarkBadge').textContent = S.cfg.titleMark; if(!s.groups) s.groups = [];
renderMarks(); if(!s.children) s.children = [];
renderSections(); if(s.header === undefined) s.header = null;
updatePreview(); if(s.footer === undefined) s.footer = null;
save(); if(s.prefix === undefined) s.prefix = null;
} if(s.suffix === undefined) s.suffix = null;
function addMark(){ S.cfg.marks.push(''); renderMarks(); renderSections(); updatePreview(); save(); } if(!s.attributes) s.attributes = {};
function removeMark(i){ if(S.cfg.marks.length<=1) return; S.cfg.marks.splice(i,1); renderMarks(); renderSections(); updatePreview(); save(); } if(!s.data_type) s.data_type = 'unset';
function setMark(i,v){ S.cfg.marks[i]=v; renderSections(); updatePreview(); save(); } if(s.value === undefined) s.value = null;
function renderMarks(){ for(const g of s.groups){
document.getElementById('marksList').innerHTML = S.cfg.marks.map((m,i)=>` if(g.cat !== undefined){ g.attributes=g.attributes||{}; if(g.cat) g.attributes.category=g.cat; delete g.cat; }
<div class="mark-chip"> if(g.header === undefined) g.header = null;
<span class="lvl">L${i+1}</span> if(g.footer === undefined) g.footer = null;
<input value="${esc(m)}" placeholder="마커" oninput="setMark(${i},this.value)"> if(g.prefix === undefined) g.prefix = null;
${S.cfg.marks.length>1?`<span class="rm" onclick="removeMark(${i})">✕</span>`:''} if(g.suffix === undefined) g.suffix = null;
</div>`).join(''); 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);
}
} }
// ════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════
@@ -389,11 +355,11 @@ function findSec(list, id){
function findGrp(secs, id){ function findGrp(secs, id){
for(const s of secs){ for(const s of secs){
for(const g of s.groups){ if(g.id===id) return g; } for(const g of s.groups){ if(g.id===id) return g; }
const found=findGrp(s.children,id); if(found) return found; const f=findGrp(s.children,id); if(f) return f;
} }
return null; return null;
} }
function findNode(id){ return findSec(S.sections,id) || findGrp(S.sections,id); } function findNode(id){ return findSec(S.sections,id)||findGrp(S.sections,id); }
function setNodeField(nid, field, val){ function setNodeField(nid, field, val){
const n=findNode(nid); if(!n) return; const n=findNode(nid); if(!n) return;
@@ -404,11 +370,9 @@ function setNodeField(nid, field, val){
// ── 어트리뷰트 ───────────────────────────────────────── // ── 어트리뷰트 ─────────────────────────────────────────
function addAttr(nid){ function addAttr(nid){
const n=findNode(nid); if(!n) return; const n=findNode(nid); if(!n) return;
// 빈 키 중복 방지
const newKey = Object.keys(n.attributes).includes('')? '_new'+Date.now() : ''; const newKey = Object.keys(n.attributes).includes('')? '_new'+Date.now() : '';
n.attributes[newKey]=''; n.attributes[newKey]='';
renderSections(); updatePreview(); save(); renderSections(); updatePreview(); save();
// 새로 생긴 키 input에 포커스
setTimeout(()=>{ setTimeout(()=>{
const rows=document.querySelectorAll(`[data-nid="${nid}"] .ns-attr-key`); const rows=document.querySelectorAll(`[data-nid="${nid}"] .ns-attr-key`);
if(rows.length) rows[rows.length-1].focus(); if(rows.length) rows[rows.length-1].focus();
@@ -421,15 +385,12 @@ function removeAttr(nid, key){
} }
function updateAttrKey(nid, oldKey, newKey){ function updateAttrKey(nid, oldKey, newKey){
const n=findNode(nid); if(!n||oldKey===newKey) return; const n=findNode(nid); if(!n||oldKey===newKey) return;
const val=n.attributes[oldKey]; const val=n.attributes[oldKey]; delete n.attributes[oldKey]; n.attributes[newKey]=val;
delete n.attributes[oldKey];
n.attributes[newKey]=val;
updatePreview(); save(); updatePreview(); save();
} }
function updateAttrVal(nid, key, val){ function updateAttrVal(nid, key, val){
const n=findNode(nid); if(!n) return; const n=findNode(nid); if(!n) return;
n.attributes[key]=val; n.attributes[key]=val; updatePreview(); save();
updatePreview(); save();
} }
// ── 노드 설정 패널 토글 ──────────────────────────────── // ── 노드 설정 패널 토글 ────────────────────────────────
@@ -441,17 +402,19 @@ function toggleSettings(nid){
// ════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════
// 섹션 CRUD // 섹션 CRUD
// ════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════
function removeSec(list,id){ return list.filter(s=>s.id!==id).map(s=>({...s,children:removeSec(s.children,id)})) } function removeSec(list, id){ return list.filter(s=>s.id!==id).map(s=>({...s,children:removeSec(s.children,id)})) }
function addSection(parentId, lv){ function addSection(parentId){
const ns=mkSec(lv); const parent = parentId ? findSec(S.sections, parentId) : null;
if(!parentId) S.sections.push(ns); const lv = parent ? parent.lv+1 : 1;
else{ const p=findSec(S.sections,parentId); if(p) p.children.push(ns); } const ns = mkSec('', '', lv);
if(!parent) S.sections.push(ns);
else parent.children.push(ns);
renderSections(); updatePreview(); save(); renderSections(); updatePreview(); save();
setTimeout(()=>{ const el=document.querySelector(`[data-sid="${ns.id}"] .sec-name-input`); if(el) el.focus(); },30); 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 deleteSection(id){ S.sections=removeSec(S.sections,id); renderSections(); updatePreview(); save(); }
function moveSec(id,dir){ 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; } 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(); move(S.sections); renderSections(); updatePreview(); save();
} }
@@ -465,34 +428,133 @@ function addGroup(sid){
renderSections(); updatePreview(); save(); renderSections(); updatePreview(); save();
setTimeout(()=>{ const el=document.querySelector(`[data-gid="${ng.id}"] .grp-names`); if(el) el.focus(); },30); setTimeout(()=>{ const el=document.querySelector(`[data-gid="${ng.id}"] .grp-names`); if(el) el.focus(); },30);
} }
function deleteGroup(sid,gid){ function deleteGroup(sid, gid){
const s=findSec(S.sections,sid); if(!s) return; const s=findSec(S.sections,sid); if(!s) return;
s.groups=s.groups.filter(g=>g.id!==gid); s.groups=s.groups.filter(g=>g.id!==gid);
renderSections(); updatePreview(); save(); renderSections(); updatePreview(); save();
} }
function moveGroup(sid,gid,dir){ function moveGroup(sid, gid, dir){
const s=findSec(S.sections,sid); if(!s) return; const s=findSec(S.sections,sid); if(!s) return;
const i=s.groups.findIndex(g=>g.id===gid); const j=i+dir; const i=s.groups.findIndex(g=>g.id===gid); const j=i+dir;
if(j<0||j>=s.groups.length) return; if(j<0||j>=s.groups.length) return;
[s.groups[i],s.groups[j]]=[s.groups[j],s.groups[i]]; [s.groups[i],s.groups[j]]=[s.groups[j],s.groups[i]];
renderSections(); updatePreview(); save(); 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 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 esc(s){ return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') }
function hasNodeExtras(n){ function hasNodeExtras(n){
return n.header||n.footer||n.prefix||n.suffix||n.value!==null||n.data_type!=='unset'|| 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'); Object.keys(n.attributes||{}).some(k=>k!=='level'&&k!=='category');
} }
function renderNodeSettings(nid, node){ function renderGroups(sid, sec){
const open = openSettings.has(nid); if(!sec.groups||!sec.groups.length) return '';
const attrs = Object.entries(node.attributes||{}).filter(([k])=>k!=='level'); return sec.groups.map(g=>{
const hasDot = hasNodeExtras(node); 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('');
}
const decoHtml = [ 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:'header',lbl:'머리글(header)'},
{f:'footer',lbl:'꼬리글(footer)'}, {f:'footer',lbl:'꼬리글(footer)'},
{f:'prefix',lbl:'접두사(prefix)'}, {f:'prefix',lbl:'접두사(prefix)'},
@@ -505,16 +567,16 @@ function renderNodeSettings(nid, node){
oninput="setNodeField('${nid}','${f}',this.value)"> oninput="setNodeField('${nid}','${f}',this.value)">
</div>`).join(''); </div>`).join('');
const attrsHtml = attrs.length ? attrs.map(([k,v])=>` const attrsHtml=attrs.length?attrs.map(([k,v])=>`
<div class="ns-attr-row"> <div class="ns-attr-row">
<input class="ns-attr-key" placeholder="키" value="${esc(k)}" <input class="ns-attr-key" placeholder="키" value="${esc(k)}"
onblur="updateAttrKey('${nid}','${esc(k)}',this.value)"> onblur="updateAttrKey('${nid}','${esc(k)}',this.value)">
<input class="ns-attr-val" placeholder="값" value="${esc(String(v??''))}" <input class="ns-attr-val" placeholder="값" value="${esc(String(v??''))}"
oninput="updateAttrVal('${nid}','${esc(k)}',this.value)"> oninput="updateAttrVal('${nid}','${esc(k)}',this.value)">
<button class="iBtn" onclick="removeAttr('${nid}','${esc(k)}')">✕</button> <button class="iBtn" onclick="removeAttr('${nid}','${esc(k)}')">✕</button>
</div>`).join('') : `<div class="ns-empty">없음</div>`; </div>`).join(''):`<div class="ns-empty">없음</div>`;
const dtOpts = DATA_TYPES.map(d=> const dtOpts=DATA_TYPES.map(d=>
`<option value="${d.v}" ${node.data_type===d.v?'selected':''}>${d.lbl}</option>`).join(''); `<option value="${d.v}" ${node.data_type===d.v?'selected':''}>${d.lbl}</option>`).join('');
return ` return `
@@ -526,6 +588,14 @@ function renderNodeSettings(nid, node){
</div> </div>
${open?` ${open?`
<div class="ns-body"> <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">
<div class="ns-section-lbl">텍스트 장식</div> <div class="ns-section-lbl">텍스트 장식</div>
<div class="ns-deco-grid">${decoHtml}</div> <div class="ns-deco-grid">${decoHtml}</div>
@@ -535,7 +605,7 @@ function renderNodeSettings(nid, node){
어트리뷰트 어트리뷰트
<button class="sBtn blue ns-add-btn" onclick="addAttr('${nid}')"> 추가</button> <button class="sBtn blue ns-add-btn" onclick="addAttr('${nid}')"> 추가</button>
</div> </div>
<div>${attrsHtml}</div> ${attrsHtml}
</div> </div>
<div class="ns-section"> <div class="ns-section">
<div class="ns-section-lbl">값(value)</div> <div class="ns-section-lbl">값(value)</div>
@@ -551,65 +621,14 @@ function renderNodeSettings(nid, node){
</div>`; </div>`;
} }
// ════════════════════════════════════════════════════════
// 빌더 렌더링
// ════════════════════════════════════════════════════════
function esc(s){ return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') }
function renderSec(sec){ function renderSec(sec){
const mark = S.cfg.marks[sec.lv-1]??''; const childrenHtml=sec.children.map(c=>renderSec(c)).join('');
const maxLv = S.cfg.marks.length; const hasChildren=sec.children.length>0;
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 ` return `
<div class="sec-card" data-level="${sec.lv}" data-sid="${sec.id}"> <div class="sec-card" data-sid="${sec.id}">
<div class="sec-head"> <div class="sec-head">
<span class="sec-mark">${esc(mark)}</span> <input class="node-mark-inp" title="구분자" value="${esc(sec.mark??'')}"
oninput="setNodeField('${sec.id}','mark',this.value)">
<input class="sec-name-input" placeholder="섹션 이름" <input class="sec-name-input" placeholder="섹션 이름"
value="${esc(sec.content??'')}" value="${esc(sec.content??'')}"
oninput="setNodeField('${sec.id}','content',this.value)"> oninput="setNodeField('${sec.id}','content',this.value)">
@@ -619,20 +638,18 @@ function renderSec(sec){
</div> </div>
<div class="sec-body"> <div class="sec-body">
${renderNodeSettings(sec.id, sec)} ${renderNodeSettings(sec.id, sec)}
${grpsHtml} <div class="children-area">
<div class="sec-actions"> ${hasChildren?`
<button class="sBtn blue" onclick="addGroup('${sec.id}')"> 이름 그룹</button> <div class="children-lbl">하위노드</div>
${canChild?`<button class="sBtn green" onclick="addSection('${sec.id}',${sec.lv+1})"> 소항목 (L${sec.lv+1})</button>`:''} <div class="children-list">${childrenHtml}</div>`:''}
<button class="btn-add-child" onclick="addSection('${sec.id}')"> 소항목</button>
</div> </div>
${childrenHtml?`<div class="children-list">${childrenHtml}</div>`:''}
</div> </div>
</div>`; </div>`;
} }
function renderSections(){ function renderSections(){
renderMarks(); document.getElementById('rootMark').value = S.titleMark;
document.getElementById('titleMarkBadge').textContent = S.cfg.titleMark;
document.getElementById('cfgTitle').value = S.cfg.titleMark;
document.getElementById('docTitle').value = S.title; document.getElementById('docTitle').value = S.title;
document.getElementById('secList').innerHTML = S.sections.map(s=>renderSec(s)).join(''); document.getElementById('secList').innerHTML = S.sections.map(s=>renderSec(s)).join('');
} }
@@ -643,21 +660,17 @@ function renderSections(){
function parseNames(raw){ return raw.trim().split(/[\s,\n]+/).filter(n=>n.length>0) } function parseNames(raw){ return raw.trim().split(/[\s,\n]+/).filter(n=>n.length>0) }
function toText(){ function toText(){
const {titleMark, marks} = S.cfg; const lines=[];
const lines = []; const title=S.title.trim();
const title = S.title.trim(); if(title){ lines.push(`${S.titleMark}${title}`); lines.push(''); }
if(title){ lines.push(`${titleMark}${title}`); lines.push(''); }
function renderSec(sec){ function renderSec(sec){
const mark = marks[sec.lv-1]??''; lines.push(`${sec.mark??''}${sec.content||''}`);
lines.push(`${mark}${sec.content||''}`);
for(const g of sec.groups){ for(const g of sec.groups){
const ns = parseNames(g.names); const ns=parseNames(g.names); if(!ns.length) continue;
if(!ns.length) continue; const cat=(g.attributes?.category??'').trim();
const cat = (g.attributes?.category??'').trim(); const pre=cat?`(${cat}) `:'';
const pre = cat?`(${cat}) `:''; const sep=g.sep??' ', npl=g.namesPerLine??0;
const sep = g.sep??' ';
const npl = g.namesPerLine??0;
if(sep==='\n'){ if(sep==='\n'){
ns.forEach((n,i)=>lines.push((i===0?pre:'')+n)); ns.forEach((n,i)=>lines.push((i===0?pre:'')+n));
} else if(npl>0){ } else if(npl>0){
@@ -714,14 +727,14 @@ function toJSON(slim=false){
id:sec.id, type:'SECTION', id:sec.id, type:'SECTION',
content:sec.content, header:sec.header, footer:sec.footer, content:sec.content, header:sec.header, footer:sec.footer,
prefix:sec.prefix, suffix:sec.suffix, prefix:sec.prefix, suffix:sec.suffix,
attributes:{level:sec.lv,...sec.attributes}, attributes:{mark:sec.mark, level:sec.lv, ...sec.attributes},
data_type:sec.data_type, value:sec.value, data_type:sec.data_type, value:sec.value,
children:[...sec.groups.map(serGrp), ...sec.children.map(serSec)] children:[...sec.groups.map(serGrp), ...sec.children.map(serSec)]
}); });
} }
const root=buildNode({ const root=buildNode({
id:'root', type:'ROOT', id:'root', type:'ROOT',
attributes:S.title.trim()?{title:S.title.trim()}:{}, attributes:S.title.trim()?{title:S.title.trim(),mark:S.titleMark}:{mark:S.titleMark},
children:S.sections.map(serSec) children:S.sections.map(serSec)
}); });
return JSON.stringify(slim?stripNulls(root):root, null, 2); return JSON.stringify(slim?stripNulls(root):root, null, 2);
@@ -750,16 +763,13 @@ function setTab(t){
} }
function updatePreview(){ function updatePreview(){
S.title = document.getElementById('docTitle')?.value??''; const el=document.getElementById('previewOut');
const el = document.getElementById('previewOut');
let content='';
if(currentTab==='text'){ if(currentTab==='text'){
content=toText(); const content=toText();
if(!content.trim()){ el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; } if(!content.trim()){ el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; }
el.className='preview-text'; el.textContent=content; el.className='preview-text'; el.textContent=content;
} else { } else {
content=toJSON(currentTab==='json'); const content=toJSON(currentTab==='json');
if(!content){ el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; }
el.className='preview-text'; el.innerHTML=highlight(content); el.className='preview-text'; el.innerHTML=highlight(content);
} }
} }
@@ -768,7 +778,7 @@ function updatePreview(){
// 복사 // 복사
// ════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════
function copyOutput(){ function copyOutput(){
const content = currentTab==='text'?toText():toJSON(currentTab==='json'); const content=currentTab==='text'?toText():toJSON(currentTab==='json');
navigator.clipboard.writeText(content).then(()=>{ navigator.clipboard.writeText(content).then(()=>{
const btn=document.getElementById('copyBtn'); const btn=document.getElementById('copyBtn');
btn.textContent='✅ 복사됨'; btn.classList.add('ok'); btn.textContent='✅ 복사됨'; btn.classList.add('ok');
@@ -777,15 +787,23 @@ function copyOutput(){
} }
// ════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════
// 불러오기 (EmojiMarkupParser JS 포트) // 불러오기
// ════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════
function openImport(){ document.getElementById('importModal').classList.add('open') } 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 closeImport(){ document.getElementById('importModal').classList.remove('open') }
function doImport(){ function doImport(){
const raw=document.getElementById('importTxt').value.trim(); if(!raw) return; const raw=document.getElementById('importTxt').value.trim(); if(!raw) return;
const titleMark=S.cfg.titleMark; const titleMark=document.getElementById('importTitleMark').value||'✅';
const sortedMarks=[...S.cfg.marks.map((m,i)=>({m,lv:i+1}))].sort((a,b)=>b.m.length-a.m.length); 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*(.*)/; const reCat=/^\(([^)]+)\)\s*(.*)/;
let title=''; const sections=[]; const secStack=[]; let pend=null; let title=''; const sections=[]; const secStack=[]; let pend=null;
@@ -804,7 +822,7 @@ function doImport(){
for(const {m,lv} of sortedMarks){ for(const {m,lv} of sortedMarks){
if(line.startsWith(m)){ if(line.startsWith(m)){
flush(); flush();
const sec=mkSec(lv, line.slice(m.length).trim()); 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; } 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); if(!secStack.length) sections.push(sec);
else{ const p=findSec(sections,secStack[secStack.length-1].id); if(p) p.children.push(sec); } else{ const p=findSec(sections,secStack[secStack.length-1].id); if(p) p.children.push(sec); }
@@ -818,7 +836,7 @@ function doImport(){
pend.names.push(...line.split(/[\s,]+/).filter(Boolean)); pend.names.push(...line.split(/[\s,]+/).filter(Boolean));
} }
flush(); flush();
S.title=title; S.sections=sections; S.titleMark=titleMark; S.title=title; S.sections=sections;
renderSections(); updatePreview(); save(); closeImport(); renderSections(); updatePreview(); save(); closeImport();
document.getElementById('importTxt').value=''; document.getElementById('importTxt').value='';
} }