From 839a3222ecaa08fb11ddb332e35df28ada078550 Mon Sep 17 00:00:00 2001 From: gm Date: Tue, 2 Jun 2026 23:54:11 +0900 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20=EB=85=B8=EB=93=9C=20=EC=A4=91?= =?UTF-8?q?=EC=8B=AC=20=EC=A0=84=EB=A9=B4=20=EA=B0=9C=ED=8E=B8=20v0.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 노드 헤더: 구분자·이름·숨기기 토글·이동·삭제 - 값 섹션: 드롭다운으로 list/node/string/integer 등 타입별 값 추가, 복수 지원 - list: 이름목록·카테고리·구분자·줄당 N명 - node: 하위 노드 카드 인라인 중첩 렌더링 - 스칼라: 단순 입력(textarea는 markdown/html/json/code) - 노드 설정 섹션: 드롭다운으로 필요한 옵션만 추가, 미선택=기본값 - header/footer/prefix/suffix/attributes - 데이터 모델: sections[]+groups[]+children[] → nodes[]+values[] 통합 - 구버전 localStorage 자동 마이그레이션 Co-Authored-By: Claude Sonnet 4.6 --- ui/builder.html | 1003 +++++++++++++++++++++++------------------------ 1 file changed, 491 insertions(+), 512 deletions(-) diff --git a/ui/builder.html b/ui/builder.html index 3113413..6c0f9d5 100644 --- a/ui/builder.html +++ b/ui/builder.html @@ -13,7 +13,7 @@ --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); + --r:8px;--sh:0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.04); --dk:#1e1e2e;--dk2:#181825;--dk3:#313244;--dk4:#45475a; --dt:#cdd6f4;--dt2:#bac2de;--dt3:#6c7086; } @@ -30,94 +30,94 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou /* ── 레이아웃 ─────────────────────────────── */ .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} +.panel-lbl{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:12px} .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-row{display:flex;align-items:center;gap:8px;background:var(--surface-alt);border:1.5px solid var(--border);border-radius:var(--r);padding:7px 10px;margin-bottom:10px;transition:border-color .15s} +.root-row:focus-within{border-color:var(--border-focus);background:var(--surface)} .root-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex-shrink:0} -.root-title-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} +.root-title{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent} +.root-title::placeholder{color:var(--muted);font-weight:400} -/* ── 구분자 입력 (공통) ───────────────────── */ -.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)} +/* ── 공통 입력 ────────────────────────────── */ +.mark-inp{width:34px;text-align:center;font-size:15px;border:1px solid var(--border);border-radius:5px;padding:2px 2px;background:var(--surface);flex-shrink:0;outline:none} +.mark-inp:focus{border-color:var(--border-focus)} +input,textarea,button,select{font-family:inherit} -/* ── 버튼 ─────────────────────────────────── */ -.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} +.iBtn.mv:hover{background:var(--accent-bg);color:var(--accent)} +.iBtn.eye{font-size:13px} +.iBtn.eye.off{color:var(--border)} +.add-node-btn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;padding:8px;background:var(--accent-bg);color:var(--accent);border:1.5px dashed var(--accent);border-radius:var(--r);cursor:pointer;font-size:12px;font-weight:600;margin-bottom:10px;transition:all .15s} +.add-node-btn:hover{background:var(--accent);color:#fff;border-style:solid} -/* ── 섹션 카드 ────────────────────────────── */ -.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-list{display:flex;flex-direction:column;gap:8px} +.node-card{border:1.5px solid var(--border);border-radius:var(--r);background:var(--surface);box-shadow:var(--sh)} +.node-card.nested{border-color:var(--accent);border-left-width:3px} +.node-head{display:flex;align-items:center;gap:5px;padding:6px 8px;background:var(--surface-alt);border-bottom:1px solid var(--border);border-radius:calc(var(--r) - 1.5px) calc(var(--r) - 1.5px) 0 0} +.node-head.is-hidden{border-bottom:none;border-radius:calc(var(--r) - 1.5px)} +.name-inp{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent;min-width:0} +.name-inp::placeholder{color:var(--muted);font-weight:400} +.node-body{padding:8px;display:flex;flex-direction:column;gap:6px} -/* ── 노드 설정 패널 ───────────────────────── */ -.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} +/* ── 섹션 (값 / 노드설정) ──────────────────── */ +.body-sec{border:1px solid var(--border);border-radius:6px;overflow:hidden} +.body-sec-hdr{display:flex;align-items:center;gap:6px;padding:5px 8px;background:var(--surface-alt);border-bottom:1px solid var(--border)} +.body-sec-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex:1} +.add-sel{font-size:11px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:var(--text);cursor:pointer;outline:none;max-width:140px} +.add-sel:focus{border-color:var(--border-focus)} +.all-set{font-size:10px;color:var(--muted);font-style:italic} +.empty-hint{font-size:11px;color:var(--muted);font-style:italic;padding:7px 8px} +.body-sec-body{padding:7px 8px;display:flex;flex-direction:column;gap:6px} -/* ── 이름 목록 그룹 ───────────────────────── */ -.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} +/* ── 값 아이템 ────────────────────────────── */ +.val-item{border:1px solid var(--border);border-radius:5px;background:var(--surface-alt);overflow:hidden} +.val-item.val-node{border:none;background:transparent;padding:0} +.val-head{display:flex;align-items:center;gap:5px;padding:4px 6px;background:var(--surface);border-bottom:1px solid var(--border)} +.val-type-badge{font-size:10px;font-weight:700;color:var(--muted);background:var(--surface-alt);border:1px solid var(--border);border-radius:3px;padding:1px 5px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0} +.val-cat-inp{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 6px;background:var(--surface);outline:none;min-width:0} +.val-cat-inp:focus{border-color:var(--border-focus)} +.val-cat-inp::placeholder{color:var(--muted)} +.val-body{padding:6px} +.val-names{width:100%;min-height:42px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:4px 6px;resize:vertical;background:var(--surface);line-height:1.55;outline:none} +.val-names:focus{border-color:var(--border-focus)} +.val-data-inp{width:100%;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:4px 6px;background:var(--surface);outline:none} +.val-data-inp:focus{border-color:var(--border-focus)} +.val-data-ta{width:100%;min-height:60px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:4px 6px;resize:vertical;background:var(--surface);font-family:monospace;line-height:1.5;outline:none} +.val-data-ta:focus{border-color:var(--border-focus)} +.val-opts{display:flex;flex-direction:column;gap:4px;margin-top:5px;padding-top:5px;border-top:1px dashed var(--border)} +.opt-row{display:flex;align-items:center;gap:5px} +.opt-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);width:36px;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{font-size:11px;padding:2px 6px;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)} +.sep-cust{width:48px;font-size:11px;border:1px solid var(--border);border-radius:4px;padding:2px 4px;background:var(--surface);text-align:center;outline:none} +.sep-cust:focus{border-color:var(--border-focus)} +.npl-inp{width:44px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 4px;text-align:center;background:var(--surface);outline:none} +.npl-inp:focus{border-color:var(--border-focus)} +.npl-inp:disabled{background:var(--surface-alt);color:var(--muted)} +.opt-hint{font-size:10px;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} +/* ── 노드 설정 ────────────────────────────── */ +.setting-row{display:flex;align-items:center;gap:5px;padding:3px 0} +.setting-lbl{font-size:11px;color:var(--muted);width:54px;flex-shrink:0} +.setting-inp{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt);outline:none} +.setting-inp:focus{border-color:var(--border-focus);background:#fff} +.attrs-hdr{display:flex;align-items:center;gap:5px;margin-bottom:4px} +.attrs-lbl{font-size:11px;color:var(--muted);flex:1} +.add-attr-btn{font-size:10px;padding:1px 7px;border-radius:4px;border:1px solid var(--accent);background:var(--accent-bg);color:var(--accent);cursor:pointer;font-weight:600} +.add-attr-btn:hover{background:var(--accent);color:#fff} +.attr-row{display:flex;align-items:center;gap:4px;margin-bottom:3px} +.attr-key{width:80px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 5px;background:var(--surface-alt);outline:none} +.attr-val{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 5px;background:var(--surface-alt);outline:none} +.attr-key:focus,.attr-val:focus{border-color:var(--border-focus);background:#fff} /* ── 미리보기 패널 ────────────────────────── */ .preview-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--dk)} @@ -147,16 +147,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou .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{width:100%;height:175px;font-family:monospace;font-size:12px;border:1.5px solid var(--border);border-radius:6px;padding:9px;resize:vertical;line-height:1.6;outline:none} +.modal textarea:focus{border-color:var(--border-focus)} .modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px} .mBtn{font-size:13px;padding:7px 16px;border-radius:6px;border:1px solid;cursor:pointer;font-weight:600} .mBtn.ok{background:var(--accent);color:#fff;border-color:var(--accent)} .mBtn.ok:hover{background:#3a5ce0} .mBtn.cancel{background:var(--surface);color:var(--muted);border-color:var(--border)} .mBtn.cancel:hover{background:var(--surface-alt)} -.sep{height:1px;background:var(--border);margin:10px 0} -input,textarea,button,select{font-family:inherit} +.hsep{height:1px;background:var(--border);margin:10px 0} @@ -164,30 +163,25 @@ input,textarea,button,select{font-family:inherit}
🏗️

CTStruct 양식 빌더

- v0.3 + v0.4
-
-
✏️ 편집기
+
✏️ 편집기
- - -
+
문서 - -
- - -
- + +
@@ -205,7 +199,6 @@ input,textarea,button,select{font-family:inherit}

     
-
@@ -218,13 +211,13 @@ input,textarea,button,select{font-family:inherit}
-
+
@@ -233,31 +226,37 @@ input,textarea,button,select{font-family:inherit} // ════════════════════════════════════════════════════════ // 상수 // ════════════════════════════════════════════════════════ -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 VALUE_TYPES = [ + {v:'list', lbl:'리스트 (List)'}, + {v:'node', lbl:'노드 (Node)'}, + {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:'markdown', lbl:'Markdown'}, + {v:'html', lbl:'HTML'}, + {v:'json', lbl:'JSON'}, + {v:'code', lbl:'코드 (Code)'}, + {v:'custom', lbl:'사용자 정의 (Custom)'}, +]; + +const SETTING_OPTIONS = [ + {v:'header', lbl:'머리글 (header)'}, + {v:'footer', lbl:'꼬리글 (footer)'}, + {v:'prefix', lbl:'접두사 (prefix)'}, + {v:'suffix', lbl:'접미사 (suffix)'}, + {v:'attributes', lbl:'어트리뷰트'}, ]; const SEP_PRESETS = [ {v:' ', lbl:'공백'}, {v:', ', lbl:', '}, {v:' / ', lbl:'/'}, - {v:'\n', lbl:'↵ 줄바꿈'}, + {v:'\n', lbl:'↵'}, ]; // ════════════════════════════════════════════════════════ @@ -265,425 +264,401 @@ const SEP_PRESETS = [ // ════════════════════════════════════════════════════════ 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 mkNode(mark='', content=''){ + return { id:uid(), mark, content, hidden:false, values:[], settings:{} }; } - -function mkSec(mark='', content='', lv=1){ - return { id:uid(), lv, mark, content, ...nodeDefaults(), groups:[], children:[] }; +function mkListVal(names='', category=''){ + return { id:uid(), type:'list', names, sep:' ', namesPerLine:0, category }; } - -function mkGrp(category='', names='', sep=' ', npl=0){ - const attrs = category ? {category} : {}; - return { id:uid(), content:null, ...nodeDefaults(), - attributes:attrs, names, sep, namesPerLine:npl }; +function mkNodeVal(childNode){ + return { id:uid(), type:'node', node: childNode ?? mkNode() }; +} +function mkScalarVal(type){ + return { id:uid(), type, data:'' }; } // ════════════════════════════════════════════════════════ // 상태 // ════════════════════════════════════════════════════════ -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 S = { titleMark:'✅', title:'예시 양식', nodes:[] }; + +// ID 맵 (렌더 전 재구성) +let nodeMap = {}; // nodeId → node +let valueMap = {}; // valueId → {val, nodeId} +let valNodeMap = {};// childNodeId → valueId (node-type value 역참조) + +function rebuildMaps(){ + nodeMap={}; valueMap={}; valNodeMap={}; + function idx(n){ + nodeMap[n.id]=n; + for(const v of n.values){ + valueMap[v.id]={val:v, nodeId:n.id}; + if(v.type==='node'){ valNodeMap[v.node.id]=v.id; idx(v.node); } + } + } + for(const n of S.nodes) idx(n); +} let currentTab = 'text'; -const openSettings = new Set(); - -// ── 영속성 ──────────────────────────────────────────── -function save(){ try{ localStorage.setItem('ctstruct-builder', JSON.stringify(S)) }catch(_){} } +// ── 영속성 ───────────────────────────────────────────── +function save(){ try{ localStorage.setItem('ctstruct-v4', JSON.stringify(S)) }catch(_){} } function load(){ try{ - const raw = localStorage.getItem('ctstruct-builder'); - if(raw){ S = JSON.parse(raw); migrateState(S); } + const raw=localStorage.getItem('ctstruct-v4'); + if(raw){ S=JSON.parse(raw); return; } + // try migrating old format + const old=localStorage.getItem('ctstruct-builder'); + if(old){ const o=JSON.parse(old); S=migrateOld(o); save(); return; } }catch(_){} + // default example + S=buildExample(); } -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 migrateOld(o){ + const titleMark = o.titleMark ?? o.cfg?.titleMark ?? '✅'; + const oldMarks = o.cfg?.marks ?? []; + const sections = o.sections ?? o.nodes ?? []; + return { + titleMark, + title: o.title ?? '', + nodes: sections.map(s => migSec(s, oldMarks)) + }; } - -function 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 migSec(s, oldMarks){ + const mark = s.mark ?? oldMarks[(s.lv??1)-1] ?? ''; + const n = mkNode(mark, s.content ?? s.name ?? ''); + n.hidden = false; + // groups → list values + for(const g of s.groups??[]){ + const lv = mkListVal(g.names??'', g.attributes?.category??''); + lv.sep = g.sep??' '; lv.namesPerLine = g.namesPerLine??0; + n.values.push(lv); } + // children → node values + for(const c of s.children??[]) n.values.push(mkNodeVal(migSec(c, oldMarks))); + // scalar value + if(s.value!=null && s.data_type && s.data_type!=='unset'){ + const sv=mkScalarVal(s.data_type); sv.data=String(s.value??''); n.values.push(sv); + } + // settings + if(s.header) n.settings.header=s.header; + if(s.footer) n.settings.footer=s.footer; + if(s.prefix) n.settings.prefix=s.prefix; + if(s.suffix) n.settings.suffix=s.suffix; + const filtAttrs=Object.entries(s.attributes??{}).filter(([k])=>k!=='level'&&k!=='category'); + if(filtAttrs.length) n.settings.attributes=Object.fromEntries(filtAttrs); + return n; +} + +function buildExample(){ + const n1=mkNode('🔹','1번 항목'); + n1.values.push(mkListVal('홍길동 홍길순 홍길영 홍길준\n홍길호 홍길윤','')); + const n2=mkNode('🔹','2번 항목'); + const c1=mkNode('🔸','2-1 항목'); c1.values.push(mkListVal('홍길찬 홍길만','건강')); + const c2=mkNode('🔸','2-2 항목'); c2.values.push(mkListVal('홍길주','직장')); + n2.values.push(mkNodeVal(c1)); n2.values.push(mkNodeVal(c2)); + return {titleMark:'✅', title:'예시 양식', nodes:[n1,n2]}; } // ════════════════════════════════════════════════════════ -// 노드 탐색 / 수정 +// CRUD — 노드 // ════════════════════════════════════════════════════════ -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 addTopNode(){ + S.nodes.push(mkNode()); refresh(); focusNew('.name-inp'); } -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 deleteNode(nid){ + const vid=valNodeMap[nid]; + if(vid){ removeValue(vid); return; } + S.nodes=S.nodes.filter(n=>n.id!==nid); + refresh(); +} +function moveNode(nid,dir){ + const vid=valNodeMap[nid]; + if(vid){ moveValue(vid,dir); return; } + const i=S.nodes.findIndex(n=>n.id===nid), j=i+dir; + if(j<0||j>=S.nodes.length) return; + [S.nodes[i],S.nodes[j]]=[S.nodes[j],S.nodes[i]]; + refresh(); +} +function toggleHidden(nid){ + const n=nodeMap[nid]; if(!n) return; + n.hidden=!n.hidden; refresh(); } -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(); +// ════════════════════════════════════════════════════════ +// CRUD — 값 +// ════════════════════════════════════════════════════════ +function addValue(nid, type){ + if(!type) return; + const n=nodeMap[nid]; if(!n) return; + let v; + if(type==='list') v=mkListVal(); + else if(type==='node') v=mkNodeVal(); + else v=mkScalarVal(type); + n.values.push(v); refresh(); + if(type==='list') focusNew('.val-names'); +} +function removeValue(vid){ + const e=valueMap[vid]; if(!e) return; + const n=nodeMap[e.nodeId]; if(!n) return; + n.values=n.values.filter(v=>v.id!==vid); refresh(); +} +function moveValue(vid,dir){ + const e=valueMap[vid]; if(!e) return; + const n=nodeMap[e.nodeId]; if(!n) return; + const i=n.values.findIndex(v=>v.id===vid), j=i+dir; + if(j<0||j>=n.values.length) return; + [n.values[i],n.values[j]]=[n.values[j],n.values[i]]; + refresh(); +} + +// ════════════════════════════════════════════════════════ +// CRUD — 설정 +// ════════════════════════════════════════════════════════ +function addSetting(nid, key){ + if(!key) return; + const n=nodeMap[nid]; if(!n||key in n.settings) return; + n.settings[key]=key==='attributes'?{}:''; refresh(); +} +function removeSetting(nid, key){ + const n=nodeMap[nid]; if(!n) return; + delete n.settings[key]; refresh(); +} +function setSettingVal(nid, key, val){ + const n=nodeMap[nid]; if(!n) return; + n.settings[key]=val||null; updatePreview(); save(); } // ── 어트리뷰트 ───────────────────────────────────────── function 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(); + const n=nodeMap[nid]; if(!n||!n.settings.attributes) return; + const k=Object.keys(n.settings.attributes).includes('')? '_'+Date.now() : ''; + n.settings.attributes[k]=''; refresh(); setTimeout(()=>{ - const rows=document.querySelectorAll(`[data-nid="${nid}"] .ns-attr-key`); + const rows=document.querySelectorAll(`[data-nid="${nid}"] .attr-key`); if(rows.length) rows[rows.length-1].focus(); },30); } +function setAttrKey(nid, old, neo){ + const n=nodeMap[nid]; if(!n||!n.settings.attributes||old===neo) return; + const v=n.settings.attributes[old]; delete n.settings.attributes[old]; + n.settings.attributes[neo]=v; updatePreview(); save(); +} +function setAttrVal(nid, key, val){ + const n=nodeMap[nid]; if(!n||!n.settings.attributes) return; + n.settings.attributes[key]=val; updatePreview(); save(); +} function removeAttr(nid, key){ - const n=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(); + const n=nodeMap[nid]; if(!n||!n.settings.attributes) return; + delete n.settings.attributes[key]; refresh(); } // ════════════════════════════════════════════════════════ -// 섹션 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 refresh(){ rebuildMaps(); renderPanel(); updatePreview(); save(); } +function focusNew(sel){ setTimeout(()=>{ const els=document.querySelectorAll(sel); if(els.length) els[els.length-1].focus(); },40); } function esc(s){ return String(s??'').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 renderPanel(){ + rebuildMaps(); + document.getElementById('rootMark').value=S.titleMark; + document.getElementById('docTitle').value=S.title; + document.getElementById('nodeList').innerHTML=S.nodes.map(n=>renderNode(n,false)).join(''); } -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) - ?`(${esc(g.attributes.category)})`:''; - const sepChips=SEP_PRESETS.map(p=>` - ${p.lbl}`).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=>``).join(''); - const grpAttrsHtml=grpAttrs.length?grpAttrs.map(([k,v])=>` -
- - - -
`).join(''):`
없음
`; - const grpDecoHtml=[{f:'header',lbl:'머리글'},{f:'footer',lbl:'꼬리글'},{f:'prefix',lbl:'접두사'},{f:'suffix',lbl:'접미사'}] - .map(({f,lbl})=>` -
- ${lbl} - -
`).join(''); - return ` -
-
- LIST - ${catBadge} - - - - -
- -
-
- ${grpOpen?'▼':'▶'} - 노드 설정 - ${grpHasDot?'':''} -
- ${grpOpen?` -
-
-
텍스트 장식
-
${grpDecoHtml}
-
-
-
- 어트리뷰트 - -
- ${grpAttrsHtml} -
-
-
값(value)
-
- - -
-
-
`:''} -
-
-
- 구분자 -
${sepChips}
- -
-
- 한 줄 - - ${isNl?'(줄바꿈 모드)':'개 (0=제한없음)'} -
-
-
`; - }).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})=>` -
- ${lbl} - -
`).join(''); - - const attrsHtml=attrs.length?attrs.map(([k,v])=>` -
- - - -
`).join(''):`
없음
`; - - const dtOpts=DATA_TYPES.map(d=> - ``).join(''); +function renderNode(node, nested){ + const valueItems=node.values.map(v=>renderValue(node.id,v)).join(''); + const typeOpts=VALUE_TYPES.map(t=>``).join(''); + const availSet=SETTING_OPTIONS.filter(o=>!(o.v in node.settings)); + const setOpts=availSet.map(o=>``).join(''); + const settingItems=Object.entries(node.settings).map(([k,v])=>renderSetting(node.id,k,v)).join(''); return ` -
-
- ${open?'▼':'▶'} - 노드 설정 - ${hasDot?'':''} +
+
+ + + + + +
- ${open?` -
- ${isSection?` -
-
- 이름 목록 - + ${!node.hidden?` +
+ +
+
+ +
- ${renderGroups(nid,node)||'
없음
'} -
`:''} -
-
텍스트 장식
-
${decoHtml}
+ ${valueItems + ?`
${valueItems}
` + :'
값 없음 — 위에서 추가
'}
-
-
- 어트리뷰트 - -
- ${attrsHtml} -
-
-
값(value)
-
- - + +
+
+ 노드 설정 + ${availSet.length + ?`` + :`모두 설정됨`}
+ ${settingItems + ?`
${settingItems}
` + :'
기본값 사용
'}
`:''}
`; } -function renderSec(sec){ - const childrenHtml=sec.children.map(c=>renderSec(c)).join(''); - const hasChildren=sec.children.length>0; - return ` -
-
- - - - - -
-
- ${renderNodeSettings(sec.id, sec)} -
- ${hasChildren?` -
하위노드
-
${childrenHtml}
`:''} - +function renderValue(nid, val){ + const mvDel=` + + + `; + + if(val.type==='node'){ + return `
${renderNode(val.node,true)}
`; + } + + if(val.type==='list'){ + const sep=val.sep??' ', npl=val.namesPerLine??0, isNl=sep==='\n'; + const chips=SEP_PRESETS.map(p=>` + ${p.lbl}`).join(''); + const customVal=SEP_PRESETS.find(p=>p.v===sep)?'':esc(sep); + return ` +
+
+ LIST + + ${mvDel}
+
+ +
+
+ 구분 +
${chips}
+ +
+
+ 줄당 + + ${isNl?'(줄바꿈 모드)':'개 (0=무제한)'} +
+
+
+
`; + } + + // 스칼라 타입 + const typeLbl=VALUE_TYPES.find(t=>t.v===val.type)?.lbl??val.type.toUpperCase(); + const isMulti=['markdown','html','json','code'].includes(val.type); + return ` +
+
+ ${val.type.toUpperCase()} + + ${mvDel} +
+
+ ${isMulti + ?`` + :`` + }
`; } -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 renderSetting(nid, key, val){ + if(key==='attributes'){ + const attrs=val??{}; + const rows=Object.entries(attrs).map(([k,v])=>` +
+ + + +
`).join(''); + return ` +
+
+ 어트리뷰트 + + +
+ ${rows||'
없음
'} +
`; + } + return ` +
+ ${key} + + +
`; } // ════════════════════════════════════════════════════════ // 텍스트 변환 // ════════════════════════════════════════════════════════ -function parseNames(raw){ return raw.trim().split(/[\s,\n]+/).filter(n=>n.length>0) } +function parseNames(raw){ return (raw??'').trim().split(/[\s,\n]+/).filter(Boolean) } 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;ilines.push((i===0?pre:'')+n)); + else if(npl>0) for(let i=0;ibuildNode({id:uid(),type:'LIST_ITEM',content:n})) + let depth=0; + function serNode(node){ + depth++; + const children=node.values.map(v=>{ + if(v.type==='list'){ + const ns=parseNames(v.names); + return buildNode({id:v.id,type:'LIST', + attributes:v.category?{category:v.category}:{}, + children:ns.map(n=>buildNode({id:uid(),type:'LIST_ITEM',content:n}))}); + } + if(v.type==='node') return serNode(v.node); + return buildNode({id:v.id,type:'VALUE',data_type:v.type,value:v.data??null}); }); - } - 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 attrs={mark:node.mark,level:depth,...(node.settings.attributes??{})}; + const r=buildNode({ + id:node.id,type:'SECTION',content:node.content, + header:node.settings.header??null, + footer:node.settings.footer??null, + prefix:node.settings.prefix??null, + suffix:node.settings.suffix??null, + attributes:attrs,children }); + depth--; + return r; } const root=buildNode({ - id:'root', type:'ROOT', + id:'root',type:'ROOT', attributes:S.title.trim()?{title:S.title.trim(),mark:S.titleMark}:{mark:S.titleMark}, - children:S.sections.map(serSec) + children:S.nodes.map(serNode) }); - return JSON.stringify(slim?stripNulls(root):root, null, 2); + return JSON.stringify(slim?stripNulls(root):root,null,2); } // ════════════════════════════════════════════════════════ @@ -761,16 +740,14 @@ function setTab(t){ 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; + const c=toText(); + if(!c.trim()){ el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; } + el.className='preview-text'; el.textContent=c; } else { - const content=toJSON(currentTab==='json'); - el.className='preview-text'; el.innerHTML=highlight(content); + el.className='preview-text'; el.innerHTML=highlight(toJSON(currentTab==='json')); } } @@ -778,8 +755,8 @@ function updatePreview(){ // 복사 // ════════════════════════════════════════════════════════ function copyOutput(){ - const content=currentTab==='text'?toText():toJSON(currentTab==='json'); - navigator.clipboard.writeText(content).then(()=>{ + const c=currentTab==='text'?toText():toJSON(currentTab==='json'); + navigator.clipboard.writeText(c).then(()=>{ const btn=document.getElementById('copyBtn'); btn.textContent='✅ 복사됨'; btn.classList.add('ok'); setTimeout(()=>{ btn.innerHTML='📋 복사'; btn.classList.remove('ok'); },2000); @@ -791,9 +768,8 @@ function copyOutput(){ // ════════════════════════════════════════════════════════ 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); + const marks=[]; function col(nodes){ for(const n of nodes){ if(n.mark&&!marks.includes(n.mark)) marks.push(n.mark); for(const v of n.values) if(v.type==='node') col([v.node]); } } + col(S.nodes); document.getElementById('importMarks').value=marks.join(',')||'🔹,🔸'; document.getElementById('importModal').classList.add('open'); } @@ -803,30 +779,34 @@ 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 sorted=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; + let title=''; const nodes=[]; const stack=[]; 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; + const parent=pend.parentId?findNodeInList(nodes,pend.parentId):null; + const lv=mkListVal(pend.names.join(' '),pend.cat||''); + if(parent) parent.values.push(lv); pend=null; } - function topId(){ return secStack.length?secStack[secStack.length-1].id:null } + function findNodeInList(list,id){ for(const n of list){ if(n.id===id) return n; for(const v of n.values) if(v.type==='node'){ const f=findNodeInList([v.node],id); if(f) return f; } } return null; } + function topId(){ return stack.length?stack[stack.length-1].id:null } for(const rawLine of raw.split('\n')){ const line=rawLine.trim(); if(!line){ flush(); continue; } if(line.startsWith(titleMark)){ flush(); title=line.slice(titleMark.length).trim(); continue; } let matched=false; - for(const {m,lv} of sortedMarks){ + for(const {m,lv} of sorted){ 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; + const node=mkNode(m, line.slice(m.length).trim()); + while(stack.length){ + const top=findNodeInList(nodes,stack[stack.length-1].id); + if(top&&stack[stack.length-1].lv>=lv) stack.pop(); else break; + } + if(!stack.length) nodes.push(node); + else{ const p=findNodeInList(nodes,stack[stack.length-1].id); if(p) p.values.push(mkNodeVal(node)); } + stack.push({id:node.id,lv}); matched=true; break; } } if(matched) continue; @@ -836,9 +816,8 @@ function doImport(){ 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=''; + S.titleMark=titleMark; S.title=title; S.nodes=nodes; + refresh(); closeImport(); document.getElementById('importTxt').value=''; } // ════════════════════════════════════════════════════════ @@ -846,15 +825,15 @@ function doImport(){ // ════════════════════════════════════════════════════════ function newDoc(){ if(!confirm('현재 내용을 모두 지우고 새 문서를 시작할까요?')) return; - S.title=''; S.sections=[]; openSettings.clear(); - renderSections(); updatePreview(); save(); + S={titleMark:'✅',title:'',nodes:[]}; refresh(); } // ════════════════════════════════════════════════════════ // 초기화 // ════════════════════════════════════════════════════════ load(); -renderSections(); +rebuildMaps(); +renderPanel(); updatePreview(); document.getElementById('importModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeImport(); }); document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeImport(); });