Files
CTStruct/ui/builder.html
gm f64f8f3eb8 ui: add Tab 3 '구조 추출' — text form to node structure extractor (v0.7)
- 텍스트 양식 붙여넣기 → 섹션 구조 실시간 추출 + 트리 미리보기
- 구분자 수동 입력 및 자동 감지 (첫 등장 순서로 L1·L2·... 결정)
- '내용 포함' 토글: 섹션 헤더만 or 내용도 값으로 포함
- '← Builder에 적용' 버튼으로 추출된 구조를 Tab 1에 로드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:59:11 +09:00

1637 lines
81 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CTStruct</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f0f2f5;--surface:#fff;--surface-alt:#f8f9fa;
--border:#e2e5ea;--border-focus:#4f6ef7;
--text:#1a1d23;--muted:#7c8594;
--accent:#4f6ef7;--accent-bg:#eef0ff;
--red:#ef4444;--red-bg:#fee2e2;
--green:#16a34a;--green-bg:#dcfce7;
--r:8px;--sh:0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.04);
--dk:#1e1e2e;--dk2:#181825;--dk3:#313244;--dk4:#45475a;
--dt:#cdd6f4;--dt2:#bac2de;--dt3:#6c7086;
}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;flex-direction:column;font-size:14px}
/* ── 헤더 ─────────────────────────────────── */
.app-header{background:var(--surface);border-bottom:1px solid var(--border);padding:10px 16px;display:flex;align-items:center;gap:10px;box-shadow:var(--sh);z-index:10;flex-shrink:0}
.app-header h1{font-size:15px;font-weight:600}
.badge{font-size:10px;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:99px;font-weight:600}
.hdr-spacer{flex:1}
.hdr-btn{display:flex;align-items:center;gap:5px;font-size:12px;padding:5px 12px;border-radius:6px;cursor:pointer;border:1px solid var(--border);background:var(--surface);color:var(--muted);transition:all .15s;font-weight:500}
.hdr-btn:hover{background:var(--surface-alt);color:var(--text);border-color:var(--border-focus)}
/* ── 메인 탭 네비게이션 ───────────────────── */
.main-nav{background:var(--surface);border-bottom:2px solid var(--border);padding:0 20px;display:flex;gap:4px;flex-shrink:0}
.main-tab{font-size:13px;font-weight:600;padding:11px 18px;border:none;background:transparent;color:var(--muted);cursor:pointer;border-bottom:2.5px solid transparent;margin-bottom:-2px;transition:all .15s;display:flex;align-items:center;gap:6px;letter-spacing:.01em}
.main-tab:hover:not(.active){color:var(--text)}
.main-tab.active{color:var(--accent);border-bottom-color:var(--accent)}
/* ── 탭 패널 ─────────────────────────────── */
.tab-panel{flex:1;display:flex;overflow:hidden}
.tab-panel.hidden{display:none!important}
/* ── 레이아웃 ─────────────────────────────── */
.app-body{display:flex;flex:1;overflow:hidden}
.builder-panel{width:460px;min-width:340px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.panel-lbl{padding:8px 12px 7px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);border-bottom:1px solid var(--border);flex-shrink:0;background:var(--surface-alt);display:flex;align-items:center;gap:6px}
.panel-lbl-title{flex:1}
.builder-scroll{flex:1;overflow-y:auto;padding:12px}
.builder-scroll::-webkit-scrollbar{width:5px}
.builder-scroll::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
/* ── 문서 루트 ────────────────────────────── */
.root-row{display:flex;align-items:center;gap:8px;background:var(--surface-alt);border:1.5px solid var(--border);border-radius:var(--r);padding:7px 10px;margin-bottom:10px;transition:border-color .15s}
.root-row:focus-within{border-color:var(--border-focus);background:var(--surface)}
.root-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex-shrink:0}
.root-title{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent}
.root-title::placeholder{color:var(--muted);font-weight:400}
/* ── 공통 입력 ────────────────────────────── */
.mark-inp{width:34px;text-align:center;font-size:15px;border:1px solid var(--border);border-radius:5px;padding:2px 2px;background:var(--surface);flex-shrink:0;outline:none}
.mark-inp:focus{border-color:var(--border-focus)}
input,textarea,button,select{font-family:inherit}
/* ── 버튼 공통 ────────────────────────────── */
.iBtn{width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:4px;border:none;cursor:pointer;font-size:12px;background:transparent;color:var(--muted);transition:all .1s;flex-shrink:0}
.iBtn:hover{background:var(--red-bg);color:var(--red)}
.iBtn.mv:hover{background:var(--accent-bg);color:var(--accent)}
.iBtn.eye{font-size:13px}
.iBtn.eye.off{color:var(--border)}
.add-node-btn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;padding:8px;background:var(--accent-bg);color:var(--accent);border:1.5px dashed var(--accent);border-radius:var(--r);cursor:pointer;font-size:12px;font-weight:600;margin-bottom:10px;transition:all .15s}
.add-node-btn:hover{background:var(--accent);color:#fff;border-style:solid}
/* ── 노드 카드 ────────────────────────────── */
.node-list{display:flex;flex-direction:column;gap:8px}
.node-card{border:1.5px solid var(--border);border-radius:var(--r);background:var(--surface);box-shadow:var(--sh)}
.node-card.nested{border-color:var(--accent);border-left-width:3px}
.node-head{display:flex;align-items:center;gap:5px;padding:6px 8px;background:var(--surface-alt);border-bottom:1px solid var(--border);border-radius:calc(var(--r) - 1.5px) calc(var(--r) - 1.5px) 0 0}
.node-head.is-hidden{border-bottom:none;border-radius:calc(var(--r) - 1.5px)}
.name-inp{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent;min-width:0}
.name-inp::placeholder{color:var(--muted);font-weight:400}
.node-body{padding:8px;display:flex;flex-direction:column;gap:6px}
/* ── 섹션 (값 / 노드설정) ──────────────────── */
.body-sec{border:1px solid var(--border);border-radius:6px;overflow:hidden}
.body-sec-hdr{display:flex;align-items:center;gap:6px;padding:5px 8px;background:var(--surface-alt);border-bottom:1px solid var(--border)}
.body-sec-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex:1}
.add-sel{font-size:11px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:var(--text);cursor:pointer;outline:none;max-width:140px}
.add-sel:focus{border-color:var(--border-focus)}
.all-set{font-size:10px;color:var(--muted);font-style:italic}
.empty-hint{font-size:11px;color:var(--muted);font-style:italic;padding:7px 8px}
.body-sec-body{padding:7px 8px;display:flex;flex-direction:column;gap:6px}
/* ── 값 아이템 ────────────────────────────── */
.val-item{border:1px solid var(--border);border-radius:5px;background:var(--surface-alt);overflow:hidden}
.val-item.val-node{border:none;background:transparent;padding:0}
.val-head{display:flex;align-items:center;gap:5px;padding:4px 6px;background:var(--surface);border-bottom:1px solid var(--border)}
.val-type-badge{font-size:10px;font-weight:700;color:var(--muted);background:var(--surface-alt);border:1px solid var(--border);border-radius:3px;padding:1px 5px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0}
.val-body{padding:6px}
.val-data-inp{width:100%;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:4px 6px;background:var(--surface);outline:none}
.val-data-inp:focus{border-color:var(--border-focus)}
.sep-chip{font-size:11px;padding:2px 8px;border-radius:4px;border:1px solid var(--border);background:var(--surface);color:var(--muted);cursor:pointer;font-weight:500;transition:all .1s;white-space:nowrap}
.sep-chip:hover{border-color:var(--accent);color:var(--accent)}
.sep-chip.on{background:var(--accent);color:#fff;border-color:var(--accent)}
/* ── 구분자/이름줄바꿈 설정 (노드 설정 내부) ─ */
.struct-sep{display:flex;flex-direction:column;gap:4px;padding-bottom:6px;margin-bottom:6px;border-bottom:1px dashed var(--border)}
.sep-bar-row{display:flex;align-items:center;gap:5px}
.sep-bar-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);width:52px;flex-shrink:0}
/* ── 노드 설정 ────────────────────────────── */
.setting-row{display:flex;align-items:center;gap:5px;padding:3px 0}
.setting-lbl{font-size:11px;color:var(--muted);width:54px;flex-shrink:0}
.setting-inp{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt);outline:none}
.setting-inp:focus{border-color:var(--border-focus);background:#fff}
.attrs-hdr{display:flex;align-items:center;gap:5px;margin-bottom:4px}
.attrs-lbl{font-size:11px;color:var(--muted);flex:1}
.attr-row{display:flex;align-items:center;gap:4px;margin-bottom:3px}
.attr-key-lbl{font-size:11px;font-weight:600;color:var(--accent);background:var(--accent-bg);border:1px solid var(--accent);border-radius:4px;padding:2px 7px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;flex-shrink:0}
.attr-val{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 5px;background:var(--surface-alt);outline:none}
.attr-val:focus{border-color:var(--border-focus);background:#fff}
/* ── 미리보기 패널 ────────────────────────── */
.preview-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--dk)}
.preview-head{display:flex;align-items:center;gap:7px;padding:9px 14px;background:var(--dk2);border-bottom:1px solid var(--dk3);flex-shrink:0}
.tabs{display:flex;gap:2px}
.tab{font-size:12px;padding:4px 11px;border-radius:5px;cursor:pointer;color:var(--dt3);border:none;background:transparent;font-weight:500;transition:all .15s}
.tab.active{background:var(--dk3);color:var(--dt)}
.p-spacer{flex:1}
.copy-btn{display:flex;align-items:center;gap:5px;font-size:12px;padding:5px 11px;background:var(--dk3);color:var(--dt2);border:1px solid var(--dk4);border-radius:6px;cursor:pointer;transition:all .15s;font-weight:500}
.copy-btn:hover{background:var(--dk4)}
.copy-btn.ok{background:#1a3a2a;color:#a6e3a1;border-color:#a6e3a1}
.preview-body{flex:1;overflow-y:auto;padding:20px 22px}
.preview-body::-webkit-scrollbar{width:5px}
.preview-body::-webkit-scrollbar-track{background:var(--dk)}
.preview-body::-webkit-scrollbar-thumb{background:var(--dk4);border-radius:3px}
.preview-text{font-family:'Cascadia Code','Fira Code','Consolas',monospace;font-size:13px;line-height:1.75;color:var(--dt);white-space:pre-wrap;word-break:break-word}
.preview-text.dim{color:var(--dt3);font-style:italic}
.j-key{color:#89b4fa}.j-str{color:#a6e3a1}.j-num{color:#fab387}.j-bool{color:#f38ba8}.j-null{color:#f38ba8;font-style:italic}
/* ── 임포트 탭 ────────────────────────────── */
.import-tabs{display:flex;gap:0;margin-bottom:12px;border-bottom:2px solid var(--border)}
.itab{font-size:12px;padding:6px 16px;border:none;background:transparent;color:var(--muted);cursor:pointer;font-weight:500;border-bottom:2px solid transparent;margin-bottom:-2px;transition:all .15s}
.itab.active{color:var(--accent);border-bottom-color:var(--accent);font-weight:700}
.itab:hover:not(.active){color:var(--text)}
/* ── 모달 ─────────────────────────────────── */
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;z-index:200}
.overlay.open{display:flex}
.modal{background:var(--surface);border-radius:12px;padding:20px;width:500px;max-width:92vw;box-shadow:0 20px 60px rgba(0,0,0,.25)}
.modal h2{font-size:15px;font-weight:700;margin-bottom:6px}
.modal p{font-size:12px;color:var(--muted);margin-bottom:10px}
.modal-field{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.modal-field label{font-size:12px;color:var(--muted);width:80px;flex-shrink:0}
.modal-field input{font-size:13px;border:1.5px solid var(--border);border-radius:6px;padding:4px 8px;flex:1;outline:none}
.modal-field input:focus{border-color:var(--border-focus)}
.modal textarea{width:100%;height:175px;font-family:monospace;font-size:12px;border:1.5px solid var(--border);border-radius:6px;padding:9px;resize:vertical;line-height:1.6;outline:none}
.modal textarea:focus{border-color:var(--border-focus)}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
.mBtn{font-size:13px;padding:7px 16px;border-radius:6px;border:1px solid;cursor:pointer;font-weight:600}
.mBtn.ok{background:var(--accent);color:#fff;border-color:var(--accent)}
.mBtn.ok:hover{background:#3a5ce0}
.mBtn.cancel{background:var(--surface);color:var(--muted);border-color:var(--border)}
.mBtn.cancel:hover{background:var(--surface-alt)}
.hsep{height:1px;background:var(--border);margin:10px 0}
/* ── Step 2: 캔버스 빌더 레이아웃 ─────────── */
#panel-builder{flex-direction:column}
.canvas-toolbar{background:var(--surface);border-bottom:1px solid var(--border);padding:8px 12px;display:flex;align-items:center;gap:8px;flex-shrink:0}
.canvas-toolbar .root-row{flex:1;margin:0;border:none;background:transparent;padding:0;box-shadow:none}
.canvas-toolbar .root-row:focus-within{border:none;background:transparent}
.builder-main{flex:1;display:flex;overflow:hidden}
.canvas-wrap{flex:1;overflow:auto;background:#eaecf2;position:relative}
.canvas-inner{position:relative}
.canvas-svg{position:absolute;top:0;left:0;pointer-events:none;overflow:visible}
/* 캔버스 노드 카드 */
.cnode{position:absolute;width:164px;background:var(--surface);border:1.5px solid var(--border);border-radius:var(--r);box-shadow:var(--sh);cursor:pointer;transition:border-color .15s,box-shadow .15s;user-select:none}
.cnode:hover{border-color:var(--border-focus)}
.cnode.selected{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,110,247,.12)}
.cnode-head{display:flex;align-items:center;gap:5px;padding:7px 9px;background:var(--surface-alt);border-bottom:1px solid var(--border);border-radius:calc(var(--r) - 1.5px) calc(var(--r) - 1.5px) 0 0}
.cnode.selected .cnode-head{background:var(--accent-bg)}
.cnode-mark{font-size:14px;flex-shrink:0;line-height:1}
.cnode-name{font-size:12px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cnode-foot{display:flex;align-items:center;padding:4px 8px;gap:4px;min-height:28px}
.cnode-vals{font-size:10px;color:var(--muted);flex:1}
.cnode-btns{display:flex;gap:2px;opacity:0;transition:opacity .1s}
.cnode:hover .cnode-btns,.cnode.selected .cnode-btns{opacity:1}
.cnode-btn{width:18px;height:18px;display:flex;align-items:center;justify-content:center;border-radius:3px;border:none;cursor:pointer;font-size:10px;background:transparent;color:var(--muted);transition:all .1s;padding:0;flex-shrink:0}
.cnode-btn:hover{background:var(--accent-bg);color:var(--accent)}
.cnode-btn.del:hover{background:var(--red-bg);color:var(--red)}
.canvas-empty{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:var(--muted);pointer-events:none;line-height:1.8}
/* 우측 패널 */
.right-panel{width:400px;min-width:260px;display:flex;flex-direction:column;overflow:hidden;border-left:1px solid var(--border)}
.detail-panel{flex:1;overflow-y:auto;background:var(--surface);display:flex;flex-direction:column}
.detail-panel::-webkit-scrollbar{width:5px}
.detail-panel::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.detail-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px;font-style:italic;text-align:center;padding:20px}
.detail-hdr-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);padding:7px 12px 6px;border-bottom:1px solid var(--border);background:var(--surface-alt);flex-shrink:0}
.detail-node-head{display:flex;align-items:center;gap:5px;padding:8px 10px;border-bottom:1px solid var(--border);background:var(--surface-alt);flex-shrink:0}
.detail-body{padding:10px;display:flex;flex-direction:column;gap:8px}
/* ── Tab 2: 내용 채우기 ──────────────────── */
#panel-filler{flex-direction:column}
.filler-toolbar{background:var(--surface);border-bottom:1px solid var(--border);padding:8px 12px;display:flex;align-items:center;gap:8px;flex-shrink:0}
.filler-title{font-size:13px;font-weight:600;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 4px}
.filler-main{flex:1;display:flex;overflow:hidden}
.filler-form-wrap{flex:1;overflow-y:auto;padding:18px 20px;background:var(--bg)}
.filler-form-wrap::-webkit-scrollbar{width:5px}
.filler-form-wrap::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.filler-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:8px;color:var(--muted);text-align:center;line-height:1.7}
.filler-preview{width:400px;min-width:260px;display:flex;flex-direction:column;border-left:1px solid var(--border)}
/* 섹션 카드 */
.fsec{margin-bottom:10px}
.fsec-head{display:flex;align-items:center;gap:7px;padding:7px 10px;background:var(--surface);border:1.5px solid var(--border);border-radius:var(--r) var(--r) 0 0}
.fsec-head.alone{border-radius:var(--r)}
.fsec-body{padding:8px;background:var(--surface);border:1.5px solid var(--border);border-top:none;border-radius:0 0 var(--r) var(--r);display:flex;flex-direction:column;gap:6px}
.fsec-body>.fsec{margin-bottom:0}
.fsec-body>.fsec .fsec-head{border-left:3px solid var(--accent);padding-left:8px}
.fsec-mark{font-size:15px;flex-shrink:0;line-height:1}
.fsec-name{font-size:13px;font-weight:500}
/* 값 입력 */
.fval-row{display:flex;flex-direction:column;gap:3px}
.fval-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
.fval-inp{width:100%;font-size:13px;border:1.5px solid var(--border);border-radius:6px;padding:6px 8px;outline:none;font-family:inherit;background:var(--surface-alt);resize:vertical;line-height:1.5}
.fval-inp:focus{border-color:var(--border-focus);background:#fff}
/* ── 양식 합치기: 입력 패널 ──────────────── */
.filler-input-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--bg)}
.ftpl-view{padding:8px 14px;border-bottom:1px solid var(--border);background:var(--surface-alt);flex-shrink:0;display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;min-height:38px;max-height:90px;overflow-y:auto}
.ftpl-view::-webkit-scrollbar{height:4px}
.ftpl-empty{font-size:11px;color:var(--muted);font-style:italic}
.ftpl-title{display:flex;align-items:center;gap:4px;font-size:12px;font-weight:700;color:var(--text);padding-right:8px;border-right:1px solid var(--border);margin-right:4px}
.ftpl-sec{display:inline-flex;align-items:center;gap:3px;font-size:11px;background:var(--surface);border:1px solid var(--border);border-radius:4px;padding:2px 7px;white-space:nowrap}
.ftpl-mark{font-size:13px;line-height:1}
.ftpl-name{font-weight:500;color:var(--text)}
.ftpl-arr{color:var(--muted);font-size:9px}
.ftpl-badge{font-size:9px;background:var(--accent-bg);color:var(--accent);border-radius:3px;padding:1px 5px;font-weight:700;margin-left:2px;letter-spacing:.04em}
.filler-textarea-wrap{flex:1;display:flex;flex-direction:column;padding:12px;overflow:hidden}
.filler-input-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:7px;display:flex;align-items:center;gap:7px}
.filler-inst-count{font-size:11px;font-weight:600;color:var(--accent);background:var(--accent-bg);border-radius:99px;padding:1px 9px;text-transform:none;letter-spacing:0}
.filler-textarea{flex:1;width:100%;font-size:13px;font-family:inherit;border:1.5px solid var(--border);border-radius:var(--r);padding:10px 12px;outline:none;resize:none;line-height:1.75;background:var(--surface);color:var(--text)}
.filler-textarea:focus{border-color:var(--border-focus)}
.filler-textarea::placeholder{color:var(--muted)}
/* ── 수집/고정 구분 ──────────────────────────── */
.cnode.collect .cnode-head{background:#e8eeff}
.cnode.collect.selected .cnode-head{background:var(--accent-bg)}
.ftpl-sec.is-collect{border-color:var(--accent);background:var(--accent-bg)}
.ftpl-role{font-size:9px;border-radius:3px;padding:1px 5px;font-weight:700;margin-left:2px;letter-spacing:.03em}
.ftpl-role.collect{background:var(--accent-bg);color:var(--accent)}
.ftpl-role.fixed{background:var(--surface-alt);color:var(--muted);border:1px solid var(--border)}
/* ── Tab 3: 구조 추출 ────────────────────────── */
#panel-extractor{flex-direction:column}
.ext-toolbar{background:var(--surface);border-bottom:1px solid var(--border);padding:8px 12px;display:flex;align-items:center;gap:8px;flex-shrink:0}
.ext-main{flex:1;display:flex;overflow:hidden}
.ext-input-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--bg)}
.ext-config{padding:7px 14px;border-bottom:1px solid var(--border);background:var(--surface-alt);display:flex;flex-wrap:wrap;gap:5px 14px;align-items:center;flex-shrink:0}
.ext-cfg-row{display:flex;align-items:center;gap:6px}
.ext-cfg-lbl{font-size:11px;font-weight:600;color:var(--muted);white-space:nowrap}
.ext-marks-inp{font-size:12px;border:1px solid var(--border);border-radius:5px;padding:3px 7px;outline:none;width:190px;background:var(--surface)}
.ext-marks-inp:focus{border-color:var(--border-focus)}
.ext-textarea-wrap{flex:1;display:flex;flex-direction:column;padding:12px;overflow:hidden}
.ext-input-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:7px;display:flex;align-items:center;gap:7px}
.ext-preview-panel{width:400px;min-width:260px;display:flex;flex-direction:column;border-left:1px solid var(--border);background:var(--surface)}
.ext-preview-head{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--border);background:var(--surface-alt);flex-shrink:0}
.ext-tree{flex:1;overflow-y:auto;padding:10px 12px}
.ext-tree::-webkit-scrollbar{width:5px}
.ext-tree::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.ext-actions{padding:10px 12px;border-top:1px solid var(--border);flex-shrink:0}
.ext-apply-btn{width:100%;padding:9px;background:var(--accent);color:#fff;border:none;border-radius:var(--r);font-size:13px;font-weight:600;cursor:pointer;transition:background .15s}
.ext-apply-btn:hover:not(:disabled){background:#3a5ce0}
.ext-apply-btn:disabled{background:var(--border);color:var(--muted);cursor:not-allowed}
.ext-node{margin-bottom:3px}
.ext-node-head{display:flex;align-items:center;gap:5px;padding:5px 8px;background:var(--surface-alt);border:1px solid var(--border);border-radius:5px;border-left-width:3px}
.ext-node-name{font-size:12px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ext-node-vals{font-size:10px;color:var(--accent);background:var(--accent-bg);border-radius:3px;padding:1px 5px;white-space:nowrap;flex-shrink:0}
.ext-node-empty{font-size:12px;color:var(--muted);font-style:italic;padding:20px;text-align:center;line-height:1.7}
</style>
</head>
<body>
<header class="app-header">
<span style="font-size:18px">🏗️</span>
<h1>CTStruct</h1>
<span class="badge">v0.7</span>
</header>
<nav class="main-nav">
<button class="main-tab active" id="mtab-builder" onclick="switchTab('builder')">✏️ 양식 만들기</button>
<button class="main-tab" id="mtab-filler" onclick="switchTab('filler')">📋 내용 채우기</button>
<button class="main-tab" id="mtab-extractor" onclick="switchTab('extractor')">🔍 구조 추출</button>
</nav>
<div id="panel-builder" class="tab-panel">
<div class="canvas-toolbar">
<span style="font-size:16px">🌳</span>
<div class="root-row">
<span class="root-lbl">문서</span>
<input class="mark-inp" id="rootMark" title="제목 구분자"
oninput="S.titleMark=this.value;updatePreview();save()">
<input class="root-title" id="docTitle" placeholder="제목 없음"
oninput="S.title=this.value;updatePreview();save()">
</div>
<button class="hdr-btn" onclick="addTopNode()"> 노드 추가</button>
<button class="hdr-btn" onclick="openImport()">📥 불러오기</button>
<button class="hdr-btn" onclick="newDoc()">🗒 새 문서</button>
</div>
<div class="builder-main">
<div class="canvas-wrap" id="canvasWrap">
<div class="canvas-inner" id="canvasInner">
<svg id="canvasSvg" class="canvas-svg"></svg>
<div id="canvasNodes"></div>
</div>
</div>
<div class="right-panel">
<div class="detail-panel" id="detailPanel">
<div class="detail-empty">← 노드를 클릭하면<br>상세 편집이 여기에 표시됩니다</div>
</div>
<div class="preview-panel">
<div class="preview-head">
<div class="tabs">
<button class="tab active" id="tab-text" onclick="setTab('text')">텍스트</button>
<button class="tab" id="tab-json" onclick="setTab('json')">JSON slim</button>
<button class="tab" id="tab-json-full" onclick="setTab('json-full')">JSON full</button>
</div>
<div class="p-spacer"></div>
<button class="copy-btn" id="copyBtn" onclick="copyOutput()">📋 복사</button>
</div>
<div class="preview-body">
<pre class="preview-text" id="previewOut"></pre>
</div>
</div>
</div>
</div>
</div><!-- /panel-builder -->
<div id="panel-filler" class="tab-panel hidden">
<div class="filler-toolbar">
<span style="font-size:16px">📋</span>
<span class="filler-title" id="fillerTitle">템플릿 없음</span>
<button class="hdr-btn" onclick="openFillerImport()">📂 템플릿 불러오기</button>
<button class="hdr-btn" onclick="clearFiller()">🗑 초기화</button>
</div>
<div class="filler-main">
<div class="filler-input-panel">
<!-- 템플릿 섹션 구조 표시 -->
<div class="ftpl-view" id="ftplView">
<span class="ftpl-empty">← 템플릿을 먼저 불러오세요</span>
</div>
<!-- 텍스트 입력 -->
<div class="filler-textarea-wrap">
<div class="filler-input-lbl">
양식 붙여넣기
<span class="filler-inst-count" id="instCount"></span>
</div>
<textarea class="filler-textarea" id="fillerInputText"
placeholder="여러 양식을 여기에 붙여넣으세요.&#10;제목 구분자(예: 📝)가 있으면 자동으로 인스턴스를 구분합니다."
oninput="parseAndMerge()"></textarea>
</div>
</div>
<div class="filler-preview">
<div class="preview-head">
<div class="tabs">
<button class="tab ftab active" id="ftab-text" onclick="setFillerTab('text')">텍스트</button>
<button class="tab ftab" id="ftab-json" onclick="setFillerTab('json')">JSON</button>
</div>
<div class="p-spacer"></div>
<button class="copy-btn" id="fCopyBtn" onclick="copyFillerOutput()">📋 복사</button>
</div>
<div class="preview-body">
<pre class="preview-text dim" id="fillerOut">양식을 붙여넣으면 합치기 결과가 표시됩니다</pre>
</div>
</div>
</div>
</div><!-- /panel-filler -->
<div id="panel-extractor" class="tab-panel hidden">
<div class="ext-toolbar">
<span style="font-size:16px">🔍</span>
<span style="font-size:13px;font-weight:600;color:var(--text)">구조 추출</span>
<div style="flex:1"></div>
<button class="hdr-btn" onclick="clearExtractor()">🗑 초기화</button>
</div>
<div class="ext-main">
<div class="ext-input-panel">
<div class="ext-config">
<div class="ext-cfg-row">
<span class="ext-cfg-lbl">제목 구분자</span>
<input class="mark-inp" id="extTitleMark" value="✅" oninput="runExtract()">
</div>
<div class="ext-cfg-row">
<span class="ext-cfg-lbl">섹션 구분자</span>
<input class="ext-marks-inp" id="extMarks" placeholder="🔹,🔸 (L1·L2 순서, 쉼표 구분)" oninput="runExtract()">
<button class="hdr-btn" onclick="autoDetectMarks()" style="padding:3px 10px;font-size:11px">자동 감지</button>
</div>
</div>
<div class="ext-textarea-wrap">
<div class="ext-input-lbl">
텍스트 양식 붙여넣기
<span class="filler-inst-count" id="extCount"></span>
</div>
<textarea class="filler-textarea" id="extInputText"
placeholder="텍스트 양식을 붙여넣으세요.&#10;섹션 구분자를 지정하거나 자동 감지를 클릭하세요.&#10;&#10;예:&#10;✅예시 양식&#10;&#10;🔹1번 항목&#10;홍길동 홍길순&#10;&#10;🔹2번 항목&#10;🔸소항목&#10;홍길찬"
oninput="runExtract()"></textarea>
</div>
</div>
<div class="ext-preview-panel">
<div class="ext-preview-head">
<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted)">추출된 구조</span>
<div style="flex:1"></div>
<label style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:4px;cursor:pointer">
<input type="checkbox" id="extKeepValues" onchange="runExtract()">
내용 포함
</label>
</div>
<div class="ext-tree" id="extTree">
<div class="ext-node-empty">텍스트를 붙여넣으면<br>구조가 여기 표시됩니다</div>
</div>
<div class="ext-actions">
<button class="ext-apply-btn" id="extApplyBtn" onclick="applyExtracted()" disabled>← Builder에 적용</button>
</div>
</div>
</div>
</div><!-- /panel-extractor -->
<div class="overlay" id="fillerImportModal">
<div class="modal">
<h2>📂 템플릿 JSON 불러오기</h2>
<p>Builder의 <strong>JSON slim</strong> 또는 <strong>JSON full</strong> 출력을 붙여넣으세요.</p>
<textarea id="fillerImportTxt" placeholder='{"type":"ROOT", "children":[...]}'></textarea>
<div class="modal-actions">
<button class="mBtn cancel" onclick="closeFillerImport()">취소</button>
<button class="mBtn ok" onclick="doLoadFillerTemplate()">불러오기</button>
</div>
</div>
</div>
<div class="overlay" id="importModal">
<div class="modal">
<h2>📥 불러오기</h2>
<div class="import-tabs">
<button class="itab active" id="itab-text" onclick="switchImportTab('text')">텍스트</button>
<button class="itab" id="itab-json" onclick="switchImportTab('json')">JSON</button>
</div>
<!-- 텍스트 탭 -->
<div id="import-text-body">
<p>기존 양식 텍스트를 붙여넣으면 편집기로 불러옵니다.</p>
<div class="modal-field">
<label>제목 구분자</label>
<input id="importTitleMark" style="width:60px;flex:none" value="✅">
</div>
<div class="modal-field">
<label>섹션 구분자들</label>
<input id="importMarks" placeholder="🔹,🔸 (쉼표 구분, L1·L2 순서)">
</div>
<div class="hsep"></div>
<textarea id="importTxt" placeholder="✅예시 양식&#10;&#10;🔹1번 항목&#10;홍길동 홍길순&#10;&#10;🔹2번 항목&#10;🔸소항목&#10;(건강) 홍길찬"></textarea>
</div>
<!-- JSON 탭 -->
<div id="import-json-body" style="display:none">
<p>JSON slim 또는 JSON full 출력을 붙여넣으면 노드로 복원합니다.<br>
<span style="font-size:11px;color:var(--muted)">※ 구분자·줄바꿈 설정은 기본값으로 초기화됩니다.</span></p>
<textarea id="importJsonTxt" placeholder='{"type":"ROOT", "children":[...]}'></textarea>
</div>
<div class="modal-actions">
<button class="mBtn cancel" onclick="closeImport()">취소</button>
<button class="mBtn ok" onclick="doImportActive()">불러오기</button>
</div>
</div>
</div>
<script>
// ════════════════════════════════════════════════════════
// 상수
// ════════════════════════════════════════════════════════
const VALUE_TYPES = [
{v:'node', lbl:'노드 (Node)'},
{v:'string', lbl:'문자열 (String)'},
{v:'int', lbl:'정수 (Int)'},
{v:'bool', lbl:'불리언 (Bool)'},
{v:'date', lbl:'날짜 (Date)'},
];
const SETTING_OPTIONS = [
{v:'prefix', lbl:'앞 문자열 (prefix)'},
{v:'suffix', lbl:'뒤 문자열 (suffix)'},
{v:'attributes', lbl:'어트리뷰트'},
];
const ATTR_KEYS = [
{v:'lang', lbl:'lang — 언어'},
{v:'id', lbl:'id — 식별자'},
{v:'class', lbl:'class — 분류'},
{v:'href', lbl:'href — 링크'},
{v:'align', lbl:'align — 정렬'},
{v:'order', lbl:'order — 순서'},
{v:'required', lbl:'required — 필수'},
{v:'readonly', lbl:'readonly — 읽기전용'},
{v:'hidden', lbl:'hidden — 숨김'},
{v:'weight', lbl:'weight — 가중치'},
{v:'style', lbl:'style — 스타일'},
{v:'_custom', lbl:'직접 입력...'},
];
// ════════════════════════════════════════════════════════
// 팩토리
// ════════════════════════════════════════════════════════
function uid(){ return Math.random().toString(36).slice(2,9) }
function mkNode(mark='', content=''){
return { id:uid(), mark, content, hidden:false, values:[], settings:{},
nameNewline:true };
}
function mkNodeVal(childNode){
return { id:uid(), type:'node', node: childNode ?? mkNode() };
}
function mkScalarVal(type){
return { id:uid(), type, data:'' };
}
// ════════════════════════════════════════════════════════
// 상태
// ════════════════════════════════════════════════════════
let S = { titleMark:'✅', title:'예시 양식', nodes:[] };
// ID 맵 (렌더 전 재구성)
let nodeMap = {}; // nodeId → node
let valueMap = {}; // valueId → {val, nodeId}
let valNodeMap = {};// childNodeId → valueId (node-type value 역참조)
function rebuildMaps(){
nodeMap={}; valueMap={}; valNodeMap={};
function idx(n){
nodeMap[n.id]=n;
for(const v of n.values){
valueMap[v.id]={val:v, nodeId:n.id};
if(v.type==='node'){ valNodeMap[v.node.id]=v.id; idx(v.node); }
}
}
for(const n of S.nodes) idx(n);
}
let currentTab = 'text';
// ── 영속성 ─────────────────────────────────────────────
function save(){ try{ localStorage.setItem('ctstruct-v4', JSON.stringify(S)) }catch(_){} }
function load(){
try{
const raw=localStorage.getItem('ctstruct-v4');
if(raw){ S=JSON.parse(raw); normalizeState(S); return; }
const old=localStorage.getItem('ctstruct-builder');
if(old){ const o=JSON.parse(old); S=migrateOld(o); save(); return; }
}catch(_){}
S=buildExample();
}
const VALID_TYPES=new Set(['node','string','int','bool','date']);
function normalizeNode(n){
if(n.nameNewline===undefined) n.nameNewline=true;
// list → string, 지원하지 않는 타입 → string
n.values=(n.values||[]).map(v=>{
if(v.type==='list'){
const sv=mkScalarVal('string'); sv.data=(v.names??'').trim(); return sv;
}
if(v.type==='node'){ normalizeNode(v.node); return v; }
if(v.type==='integer') v.type='int';
if(v.type==='boolean') v.type='bool';
if(!VALID_TYPES.has(v.type)) v.type='string';
return v;
});
}
function normalizeState(s){
for(const n of s.nodes||[]) normalizeNode(n);
}
function migrateOld(o){
const titleMark = o.titleMark ?? o.cfg?.titleMark ?? '✅';
const oldMarks = o.cfg?.marks ?? [];
const sections = o.sections ?? o.nodes ?? [];
return {
titleMark,
title: o.title ?? '',
nodes: sections.map(s => migSec(s, oldMarks))
};
}
function migSec(s, oldMarks){
const mark = s.mark ?? oldMarks[(s.lv??1)-1] ?? '';
const n = mkNode(mark, s.content ?? s.name ?? '');
// groups → string values (names 합치기)
for(const g of s.groups??[]){
const names=(g.names??'').trim(); if(!names) continue;
const sv=mkScalarVal('string'); sv.data=names; n.values.push(sv);
}
for(const c of s.children??[]) n.values.push(mkNodeVal(migSec(c, oldMarks)));
if(s.value!=null && s.data_type && s.data_type!=='unset'){
const sv=mkScalarVal('string'); sv.data=String(s.value??''); n.values.push(sv);
}
if(s.prefix) n.settings.prefix=s.prefix;
if(s.suffix) n.settings.suffix=s.suffix;
const filtAttrs=Object.entries(s.attributes??{}).filter(([k])=>k!=='level'&&k!=='category'&&k!=='mark');
if(filtAttrs.length) n.settings.attributes=Object.fromEntries(filtAttrs);
return n;
}
function buildExample(){
const n1=mkNode('🔹','1번 항목');
const sv1=mkScalarVal('string'); sv1.data='홍길동 홍길순 홍길영 홍길준'; n1.values.push(sv1);
const n2=mkNode('🔹','2번 항목');
const c1=mkNode('🔸','2-1 항목'); const sv2=mkScalarVal('string'); sv2.data='홍길찬 홍길만'; c1.values.push(sv2);
const c2=mkNode('🔸','2-2 항목'); const sv3=mkScalarVal('string'); sv3.data='홍길주'; c2.values.push(sv3);
n2.values.push(mkNodeVal(c1)); n2.values.push(mkNodeVal(c2));
return {titleMark:'✅', title:'예시 양식', nodes:[n1,n2]};
}
// ════════════════════════════════════════════════════════
// CRUD — 노드
// ════════════════════════════════════════════════════════
function addTopNode(){
const node=mkNode();
S.nodes.push(node);
selectedNodeId=node.id;
refresh();
}
function deleteNode(nid){
const vid=valNodeMap[nid];
if(vid){ removeValue(vid); return; }
S.nodes=S.nodes.filter(n=>n.id!==nid);
refresh();
}
function moveNode(nid,dir){
const vid=valNodeMap[nid];
if(vid){ moveValue(vid,dir); return; }
const i=S.nodes.findIndex(n=>n.id===nid), j=i+dir;
if(j<0||j>=S.nodes.length) return;
[S.nodes[i],S.nodes[j]]=[S.nodes[j],S.nodes[i]];
refresh();
}
function toggleHidden(nid){
const n=nodeMap[nid]; if(!n) return;
n.hidden=!n.hidden; refresh();
}
// ════════════════════════════════════════════════════════
// CRUD — 값
// ════════════════════════════════════════════════════════
function addValue(nid, type){
if(!type) return;
const n=nodeMap[nid]; if(!n) return;
const v = type==='node' ? mkNodeVal() : mkScalarVal(type);
n.values.push(v);
if(type==='node') selectedNodeId=v.node.id;
refresh();
}
function removeValue(vid){
const e=valueMap[vid]; if(!e) return;
const n=nodeMap[e.nodeId]; if(!n) return;
n.values=n.values.filter(v=>v.id!==vid); refresh();
}
function moveValue(vid,dir){
const e=valueMap[vid]; if(!e) return;
const n=nodeMap[e.nodeId]; if(!n) return;
const i=n.values.findIndex(v=>v.id===vid), j=i+dir;
if(j<0||j>=n.values.length) return;
[n.values[i],n.values[j]]=[n.values[j],n.values[i]];
refresh();
}
// ════════════════════════════════════════════════════════
// CRUD — 설정
// ════════════════════════════════════════════════════════
function addSetting(nid, key){
if(!key) return;
const n=nodeMap[nid]; if(!n||key in n.settings) return;
n.settings[key]=key==='attributes'?{}:''; refresh();
}
function removeSetting(nid, key){
const n=nodeMap[nid]; if(!n) return;
delete n.settings[key]; refresh();
}
function setSettingVal(nid, key, val){
const n=nodeMap[nid]; if(!n) return;
n.settings[key]=val||null; updatePreview(); save();
}
// ── 어트리뷰트 ─────────────────────────────────────────
function handleAddAttr(nid, key){
if(!key) return;
if(key==='_custom'){
const k=(prompt('어트리뷰트 키 이름 입력:')??'').trim();
if(k) addAttr(nid, k);
} else {
addAttr(nid, key);
}
}
function addAttr(nid, key){
const n=nodeMap[nid]; if(!n||!n.settings.attributes||key in n.settings.attributes) return;
n.settings.attributes[key]=''; refresh();
setTimeout(()=>{
const inp=document.querySelector(`[data-attrnode="${nid}"][data-attrkey] .attr-val`);
// focus the last added attr-val
const all=document.querySelectorAll(`[data-attrnode="${nid}"] .attr-val`);
if(all.length) all[all.length-1].focus();
},40);
}
function setAttrVal(nid, key, val){
const n=nodeMap[nid]; if(!n||!n.settings.attributes) return;
n.settings.attributes[key]=val; updatePreview(); save();
}
function removeAttr(nid, key){
const n=nodeMap[nid]; if(!n||!n.settings.attributes) return;
delete n.settings.attributes[key]; refresh();
}
// ════════════════════════════════════════════════════════
// 헬퍼
// ════════════════════════════════════════════════════════
function refresh(){ rebuildMaps(); renderCanvas(); renderDetail(); updatePreview(); save(); }
function esc(s){ return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') }
// 입력값의 \n \t → 실제 문자 / 실제 문자 → 표시용 이스케이프
function unesc(s){ return s.replace(/\\n/g,'\n').replace(/\\t/g,'\t').replace(/\\r/g,'\r') }
function reesc(s){ return s.replace(/\r/g,'\\r').replace(/\n/g,'\\n').replace(/\t/g,'\\t') }
// ════════════════════════════════════════════════════════
// 캔버스 레이아웃
// ════════════════════════════════════════════════════════
let selectedNodeId = null;
const C_W=164, C_H=54, C_COL=252, C_ROW=72, C_PAD=28;
function subtreeH(node){
const kids=node.values.filter(v=>v.type==='node').map(v=>v.node);
if(!kids.length) return 1;
return kids.reduce((s,c)=>s+subtreeH(c),0);
}
function computeLayout(){
const pos=new Map();
function place(node,col,ySlot){
const kids=node.values.filter(v=>v.type==='node').map(v=>v.node);
const h=subtreeH(node);
pos.set(node.id,{x:C_PAD+col*C_COL, y:C_PAD+C_ROW*(ySlot+h/2)-C_H/2});
let y=ySlot;
for(const kid of kids){ place(kid,col+1,y); y+=subtreeH(kid); }
}
let y=0;
for(const node of S.nodes){ place(node,0,y); y+=subtreeH(node); }
return pos;
}
function renderCanvas(){
rebuildMaps();
document.getElementById('rootMark').value=S.titleMark;
document.getElementById('docTitle').value=S.title;
if(S.nodes.length===0){
document.getElementById('canvasNodes').innerHTML=
`<div class="canvas-empty"><div style="font-size:38px">🌱</div><div style="margin-top:8px;font-size:13px;font-weight:500">노드가 없습니다</div><div style="font-size:11px;margin-top:4px;color:var(--muted)">위의 노드 추가 버튼으로 시작하세요</div></div>`;
document.getElementById('canvasSvg').innerHTML='';
return;
}
const pos=computeLayout();
const wrap=document.getElementById('canvasWrap');
const inner=document.getElementById('canvasInner');
let maxX=wrap.clientWidth||600, maxY=wrap.clientHeight||400;
pos.forEach(({x,y})=>{ maxX=Math.max(maxX,x+C_W+C_PAD); maxY=Math.max(maxY,y+C_H+C_PAD); });
inner.style.width=maxX+'px'; inner.style.height=maxY+'px';
// SVG 연결선
let paths='';
function drawLines(node){
const p=pos.get(node.id); if(!p) return;
const px=p.x+C_W, py=p.y+C_H/2;
const kids=node.values.filter(v=>v.type==='node').map(v=>v.node);
for(const kid of kids){
const c=pos.get(kid.id); if(!c) continue;
const cx=c.x, cy=c.y+C_H/2;
const mx=(px+cx)/2;
const sel=(node.id===selectedNodeId||kid.id===selectedNodeId);
paths+=`<path d="M${px},${py} C${mx},${py} ${mx},${cy} ${cx},${cy}" fill="none" stroke="${sel?'#4f6ef7':'#c5cae0'}" stroke-width="${sel?2:1.5}"/>`;
drawLines(kid);
}
}
for(const node of S.nodes) drawLines(node);
const svg=document.getElementById('canvasSvg');
svg.style.width=maxX+'px'; svg.style.height=maxY+'px';
svg.setAttribute('viewBox',`0 0 ${maxX} ${maxY}`);
svg.innerHTML=paths;
// 노드 카드
let cards='';
function renderCNode(node){
const p=pos.get(node.id); if(!p) return;
const sel=node.id===selectedNodeId;
const col=node.settings?.attributes?.collect==='true';
const vals=node.values.filter(v=>v.type!=='node').length;
const kids=node.values.filter(v=>v.type==='node').length;
const hint=[vals?`${vals}`:'',kids?`하위 ${kids}`:''].filter(Boolean).join(' · ')||'비어있음';
cards+=`<div class="cnode${sel?' selected':''}${col?' collect':''}" style="left:${p.x}px;top:${p.y}px" onclick="selectNode('${node.id}')">
<div class="cnode-head">
<span class="cnode-mark">${esc(node.mark||'·')}</span>
<span class="cnode-name" title="${esc(node.content||'')}">${esc(node.content||'이름 없음')}</span>
${col?'<span style="font-size:9px;color:var(--accent);font-weight:700;flex-shrink:0;line-height:1">📥</span>':''}
</div>
<div class="cnode-foot">
<span class="cnode-vals">${hint}</span>
<div class="cnode-btns">
<button class="cnode-btn" title="위로" onclick="event.stopPropagation();moveNode('${node.id}',-1)">↑</button>
<button class="cnode-btn" title="아래로" onclick="event.stopPropagation();moveNode('${node.id}',1)">↓</button>
<button class="cnode-btn" title="하위 노드 추가" onclick="event.stopPropagation();addChildNode('${node.id}')"></button>
<button class="cnode-btn del" title="삭제" onclick="event.stopPropagation();deleteFromCanvas('${node.id}')">✕</button>
</div>
</div>
</div>`;
for(const v of node.values) if(v.type==='node') renderCNode(v.node);
}
for(const node of S.nodes) renderCNode(node);
document.getElementById('canvasNodes').innerHTML=cards;
}
function selectNode(nid){
selectedNodeId=nid;
renderCanvas();
renderDetail();
}
function addChildNode(parentNid){
const parent=nodeMap[parentNid]; if(!parent) return;
const child=mkNode();
parent.values.push(mkNodeVal(child));
selectedNodeId=child.id;
refresh();
}
function deleteFromCanvas(nid){
if(nid===selectedNodeId) selectedNodeId=null;
deleteNode(nid);
}
function renderDetail(){
const panel=document.getElementById('detailPanel'); if(!panel) return;
if(!selectedNodeId||!nodeMap[selectedNodeId]){
panel.innerHTML='<div class="detail-empty">← 노드를 클릭하면<br>상세 편집이 여기에 표시됩니다</div>';
return;
}
const node=nodeMap[selectedNodeId];
const valItems=node.values.map(v=>renderValue(node.id,v)).join('');
const typeOpts=VALUE_TYPES.map(t=>`<option value="${t.v}">${t.lbl}</option>`).join('');
const availSet=SETTING_OPTIONS.filter(o=>!(o.v in node.settings));
const setOpts=availSet.map(o=>`<option value="${o.v}">${o.lbl}</option>`).join('');
const settingItems=Object.entries(node.settings).map(([k,v])=>renderSetting(node.id,k,v)).join('');
panel.innerHTML=`
<div class="detail-hdr-lbl">📌 노드 편집</div>
<div class="detail-node-head">
<input class="mark-inp" title="구분자" value="${esc(node.mark??'')}"
oninput="nodeMap['${node.id}'].mark=this.value;updatePreview();save();renderCanvas()">
<input class="name-inp" placeholder="노드 이름" value="${esc(node.content??'')}"
oninput="nodeMap['${node.id}'].content=this.value;updatePreview();save();renderCanvas()">
<button class="iBtn eye${node.hidden?' off':''}" title="숨기기 토글" onclick="toggleHidden('${node.id}')">👁</button>
<button class="iBtn mv" onclick="moveNode('${node.id}',-1)" title="위로">↑</button>
<button class="iBtn mv" onclick="moveNode('${node.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="deleteFromCanvas('${node.id}')" title="삭제">✕</button>
</div>
<div class="detail-body">
<div class="body-sec">
<div class="body-sec-hdr">
<span class="body-sec-title">값</span>
<select class="add-sel" onchange="addValue('${node.id}',this.value);this.selectedIndex=0">
<option value=""> 추가</option>${typeOpts}
</select>
</div>
${valItems?`<div class="body-sec-body">${valItems}</div>`:'<div class="empty-hint">값 없음 — 위에서 추가</div>'}
</div>
<div class="body-sec">
<div class="body-sec-hdr">
<span class="body-sec-title">노드 설정</span>
${availSet.length?`<select class="add-sel" onchange="addSetting('${node.id}',this.value);this.selectedIndex=0">
<option value=""> 옵션 추가</option>${setOpts}</select>`:''}
</div>
<div class="body-sec-body">${renderStructSettings(node)}${settingItems}</div>
</div>
</div>`;
}
function renderValue(nid, val){
const mvDel=`
<button class="iBtn mv" onclick="moveValue('${val.id}',-1)" title="위로">↑</button>
<button class="iBtn mv" onclick="moveValue('${val.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="removeValue('${val.id}')" title="삭제">✕</button>`;
if(val.type==='node'){
const cn=val.node;
return `<div class="val-item" data-vid="${val.id}">
<div class="val-head">
<span class="val-type-badge">NODE</span>
<span style="flex:1;font-size:12px;padding:0 5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc((cn.mark||'')+' '+(cn.content||'이름 없음'))}</span>
<button class="iBtn" title="이 노드 선택" onclick="event.stopPropagation();selectNode('${cn.id}')">→</button>
<button class="iBtn mv" onclick="event.stopPropagation();moveValue('${val.id}',-1)" title="위로">↑</button>
<button class="iBtn mv" onclick="event.stopPropagation();moveValue('${val.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="event.stopPropagation();removeValue('${val.id}')" title="삭제">✕</button>
</div>
</div>`;
}
const typeLbl=VALUE_TYPES.find(t=>t.v===val.type)?.lbl??val.type.toUpperCase();
return `
<div class="val-item" data-vid="${val.id}">
<div class="val-head">
<span class="val-type-badge">${val.type.toUpperCase()}</span>
<span style="flex:1"></span>
${mvDel}
</div>
<div class="val-body">
<input class="val-data-inp" placeholder="${esc(typeLbl)} 입력"
value="${esc(val.data??'')}"
oninput="valueMap['${val.id}'].val.data=this.value;updatePreview();save()">
</div>
</div>`;
}
function setNameNL(nid, val){
const n=nodeMap[nid]; if(!n) return;
n.nameNewline=val; refresh();
}
function setCollect(nid, on){
const n=nodeMap[nid]; if(!n) return;
if(on){
if(!n.settings.attributes) n.settings.attributes={};
n.settings.attributes.collect='true';
} else {
if(n.settings.attributes){ delete n.settings.attributes.collect; }
if(n.settings.attributes && !Object.keys(n.settings.attributes).length) delete n.settings.attributes;
}
refresh();
}
function renderStructSettings(node){
const nid=node.id, nl=node.nameNewline??true;
const col=node.settings?.attributes?.collect==='true';
return `
<div class="struct-sep">
<div class="sep-bar-row">
<span class="sep-bar-lbl">이름 뒤</span>
<span class="sep-chip${nl?' on':''}" onclick="setNameNL('${nid}',true)">줄바꿈</span>
<span class="sep-chip${!nl?' on':''}" onclick="setNameNL('${nid}',false)">없음</span>
</div>
<div class="sep-bar-row">
<span class="sep-bar-lbl">역할</span>
<span class="sep-chip${!col?' on':''}" onclick="setCollect('${nid}',false)" title="값이 항상 고정 출력됩니다">🔒 고정</span>
<span class="sep-chip${col?' on':''}" onclick="setCollect('${nid}',true)" title="합치기 시 값이 수집됩니다">📥 수집</span>
</div>
</div>`;
}
function renderSetting(nid, key, val){
if(key==='attributes'){
const attrs=val??{};
const usedKeys=Object.keys(attrs);
const availKeys=ATTR_KEYS.filter(o=>o.v==='_custom'||!usedKeys.includes(o.v));
const attrKeyOpts=availKeys.map(o=>`<option value="${esc(o.v)}">${esc(o.lbl)}</option>`).join('');
const rows=Object.entries(attrs).map(([k,v])=>{
const def=ATTR_KEYS.find(o=>o.v===k);
const lbl=def?def.lbl:k;
return `
<div class="attr-row" data-attrnode="${nid}" data-attrkey="${esc(k)}">
<span class="attr-key-lbl" title="${esc(k)}">${esc(lbl)}</span>
<input class="attr-val" placeholder="값" value="${esc(String(v??''))}"
oninput="setAttrVal('${nid}','${esc(k)}',this.value)">
<button class="iBtn" onclick="removeAttr('${nid}','${esc(k)}')">✕</button>
</div>`;
}).join('');
return `
<div>
<div class="attrs-hdr">
<span class="attrs-lbl">어트리뷰트</span>
<select class="add-sel" onchange="handleAddAttr('${nid}',this.value);this.selectedIndex=0">
<option value=""> 추가</option>
${attrKeyOpts}
</select>
<button class="iBtn" onclick="removeSetting('${nid}','attributes')">✕</button>
</div>
${rows||'<div class="empty-hint" style="padding:2px 0">없음</div>'}
</div>`;
}
return `
<div class="setting-row">
<span class="setting-lbl">${key}</span>
<input class="setting-inp" placeholder="\\n \\t 사용 가능" value="${esc(reesc(String(val??'')))}"
oninput="setSettingVal('${nid}','${key}',unesc(this.value))">
<button class="iBtn" onclick="removeSetting('${nid}','${key}')">✕</button>
</div>`;
}
// ════════════════════════════════════════════════════════
// 텍스트 변환
// ════════════════════════════════════════════════════════
function parseNames(raw){ return (raw??'').trim().split(/[\s,\n]+/).filter(Boolean) }
function toText(){
function rNode(node){
const pre=node.settings.prefix??'';
const suf=node.settings.suffix??'';
const nl=(node.nameNewline??true)?'\n':'';
let t=pre+`${node.mark??''}${node.content||''}`;
let first=true;
for(const v of node.values){
if(v.type==='node'){
t+=rNode(v.node); first=false;
} else if(v.data!==undefined&&v.data!==null&&String(v.data).length){
t+=(first?nl:'\n')+String(v.data); first=false;
}
}
t+=suf;
return t;
}
const title=S.title.trim();
let out=title?`${S.titleMark}${title}\n`:'';
out+=S.nodes.map(rNode).join('');
return out.replace(/\n{3,}/g,'\n\n').trimEnd()+'\n';
}
// ════════════════════════════════════════════════════════
// JSON 변환
// ════════════════════════════════════════════════════════
function stripNulls(obj){
if(Array.isArray(obj)) return obj.map(stripNulls);
if(obj&&typeof obj==='object'){
const r={};
for(const [k,v] of Object.entries(obj)){
if(v===null) continue;
if(k==='data_type'&&v==='unset') continue;
if(k==='attributes'&&typeof v==='object'&&!Object.keys(v).length) continue;
r[k]=stripNulls(v);
}
return r;
}
return obj;
}
function buildNode(o){
return {content:null,prefix:null,suffix:null,
attributes:{},data_type:'unset',value:null,children:[],...o};
}
function toJSON(slim=false){
let depth=0;
function serNode(node){
depth++;
const children=node.values.map(v=>{
if(v.type==='node') return serNode(v.node);
return buildNode({id:v.id,type:'VALUE',data_type:v.type,value:v.data??null});
});
const attrs={mark:node.mark,level:depth,...(node.settings.attributes??{})};
const r=buildNode({
id:node.id,type:'SECTION',content:node.content,
prefix:node.settings.prefix??null,
suffix:node.settings.suffix??null,
attributes:attrs,children,
nameNewline:node.nameNewline??true,
});
depth--;
return r;
}
const root=buildNode({
id:'root',type:'ROOT',
attributes:S.title.trim()?{title:S.title.trim(),mark:S.titleMark}:{mark:S.titleMark},
children:S.nodes.map(serNode)
});
return JSON.stringify(slim?stripNulls(root):root,null,2);
}
// ════════════════════════════════════════════════════════
// JSON 구문 강조
// ════════════════════════════════════════════════════════
function highlight(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/("(?:[^"\\]|\\.)*")(\s*:)/g,'<span class="j-key">$1</span>$2')
.replace(/:\s*("(?:[^"\\]|\\.)*")/g,': <span class="j-str">$1</span>')
.replace(/:\s*(-?\d+\.?\d*)/g,': <span class="j-num">$1</span>')
.replace(/:\s*(true|false)/g,': <span class="j-bool">$1</span>')
.replace(/:\s*(null)/g,': <span class="j-null">$1</span>');
}
// ════════════════════════════════════════════════════════
// 미리보기
// ════════════════════════════════════════════════════════
function setTab(t){
currentTab=t;
document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));
document.getElementById('tab-'+t).classList.add('active');
updatePreview();
}
function updatePreview(){
const el=document.getElementById('previewOut');
if(currentTab==='text'){
const c=toText();
if(!c.trim()){ el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; }
el.className='preview-text'; el.textContent=c;
} else {
el.className='preview-text'; el.innerHTML=highlight(toJSON(currentTab==='json'));
}
}
// ════════════════════════════════════════════════════════
// 복사
// ════════════════════════════════════════════════════════
function copyOutput(){
const c=currentTab==='text'?toText():toJSON(currentTab==='json');
navigator.clipboard.writeText(c).then(()=>{
const btn=document.getElementById('copyBtn');
btn.textContent='✅ 복사됨'; btn.classList.add('ok');
setTimeout(()=>{ btn.innerHTML='📋 복사'; btn.classList.remove('ok'); },2000);
});
}
// ════════════════════════════════════════════════════════
// 불러오기
// ════════════════════════════════════════════════════════
let importTab='text';
function switchImportTab(tab){
importTab=tab;
document.getElementById('import-text-body').style.display=tab==='text'?'':'none';
document.getElementById('import-json-body').style.display=tab==='json'?'':'none';
document.querySelectorAll('.itab').forEach(el=>el.classList.remove('active'));
document.getElementById('itab-'+tab).classList.add('active');
}
function openImport(){
document.getElementById('importTitleMark').value=S.titleMark;
const marks=[]; function col(nodes){ for(const n of nodes){ if(n.mark&&!marks.includes(n.mark)) marks.push(n.mark); for(const v of n.values) if(v.type==='node') col([v.node]); } }
col(S.nodes);
document.getElementById('importMarks').value=marks.join(',')||'🔹,🔸';
document.getElementById('importModal').classList.add('open');
}
function closeImport(){ document.getElementById('importModal').classList.remove('open') }
function doImportActive(){
if(importTab==='json') doImportJson(); else doImport();
}
// ── JSON 파서 ──────────────────────────────────────────
function doImportJson(){
const raw=document.getElementById('importJsonTxt').value.trim();
if(!raw) return;
let obj;
try{ obj=JSON.parse(raw); }
catch(e){ alert('JSON 파싱 오류:\n'+e.message); return; }
if(obj.type!=='ROOT'){ alert('최상위 타입이 ROOT가 아닙니다.'); return; }
S.titleMark = obj.attributes?.mark ?? '✅';
S.title = obj.attributes?.title ?? '';
S.nodes = (obj.children??[]).filter(c=>c.type==='SECTION').map(parseJsonSection);
refresh(); closeImport();
document.getElementById('importJsonTxt').value='';
}
function parseJsonSection(sec){
const mark=sec.attributes?.mark??'';
const n=mkNode(mark, sec.content??'');
if(sec.nameNewline!==undefined) n.nameNewline=sec.nameNewline;
if(sec.prefix) n.settings.prefix=sec.prefix;
if(sec.suffix) n.settings.suffix=sec.suffix;
const filtAttrs=Object.entries(sec.attributes??{}).filter(([k])=>k!=='mark'&&k!=='level');
if(filtAttrs.length) n.settings.attributes=Object.fromEntries(filtAttrs);
for(const child of sec.children??[]){
if(child.type==='SECTION'){
n.values.push(mkNodeVal(parseJsonSection(child)));
} else if(child.type==='VALUE'){
const sv=mkScalarVal(child.data_type??'string');
sv.data=String(child.value??'');
n.values.push(sv);
} else if(child.type==='LIST'){
// 구버전 호환: LIST → string value
const names=(child.children??[]).filter(c=>c.type==='LIST_ITEM').map(c=>c.content??'').join(' ');
const sv=mkScalarVal('string'); sv.data=names; n.values.push(sv);
}
}
return n;
}
function doImport(){
const raw=document.getElementById('importTxt').value.trim(); if(!raw) return;
const titleMark=document.getElementById('importTitleMark').value||'✅';
const marks=document.getElementById('importMarks').value.split(',').map(m=>m.trim()).filter(Boolean);
const sorted=marks.map((m,i)=>({m,lv:i+1})).sort((a,b)=>b.m.length-a.m.length);
const reCat=/^\(([^)]+)\)\s*(.*)/;
let title=''; const nodes=[]; const stack=[]; let pend=null;
function flush(){
if(!pend||!pend.names.length){ pend=null; return; }
const parent=pend.parentId?findNodeInList(nodes,pend.parentId):null;
const sv=mkScalarVal('string'); sv.data=pend.names.join(' ');
if(parent) parent.values.push(sv); pend=null;
}
function findNodeInList(list,id){ for(const n of list){ if(n.id===id) return n; for(const v of n.values) if(v.type==='node'){ const f=findNodeInList([v.node],id); if(f) return f; } } return null; }
function topId(){ return stack.length?stack[stack.length-1].id:null }
for(const rawLine of raw.split('\n')){
const line=rawLine.trim(); if(!line){ flush(); continue; }
if(line.startsWith(titleMark)){ flush(); title=line.slice(titleMark.length).trim(); continue; }
let matched=false;
for(const {m,lv} of sorted){
if(line.startsWith(m)){
flush();
const node=mkNode(m, line.slice(m.length).trim());
while(stack.length){
const top=findNodeInList(nodes,stack[stack.length-1].id);
if(top&&stack[stack.length-1].lv>=lv) stack.pop(); else break;
}
if(!stack.length) nodes.push(node);
else{ const p=findNodeInList(nodes,stack[stack.length-1].id); if(p) p.values.push(mkNodeVal(node)); }
stack.push({id:node.id,lv}); matched=true; break;
}
}
if(matched) continue;
const cm=reCat.exec(line);
if(cm){ flush(); pend={parentId:topId(),cat:cm[1].trim(),names:[]}; const rest=cm[2].trim(); if(rest) pend.names.push(...rest.split(/[\s,]+/).filter(Boolean)); continue; }
if(!pend) pend={parentId:topId(),cat:'',names:[]};
pend.names.push(...line.split(/[\s,]+/).filter(Boolean));
}
flush();
S.titleMark=titleMark; S.title=title; S.nodes=nodes;
refresh(); closeImport(); document.getElementById('importTxt').value='';
}
// ════════════════════════════════════════════════════════
// 새 문서
// ════════════════════════════════════════════════════════
function newDoc(){
if(!confirm('현재 내용을 모두 지우고 새 문서를 시작할까요?')) return;
S={titleMark:'✅',title:'',nodes:[]}; refresh();
}
// ════════════════════════════════════════════════════════
// Tab 2: 양식 합치기 (텍스트 붙여넣기 방식)
// ════════════════════════════════════════════════════════
let FT = null; // filler template (parsed JSON root)
let fillerTab = 'text';
let fillerMerged = {}; // sectionKey → string[]
let fillerInstCount = 0;
function openFillerImport(){ document.getElementById('fillerImportModal').classList.add('open') }
function closeFillerImport(){ document.getElementById('fillerImportModal').classList.remove('open') }
function doLoadFillerTemplate(){
const raw=document.getElementById('fillerImportTxt').value.trim(); if(!raw) return;
let obj;
try{ obj=JSON.parse(raw); }catch(e){ alert('JSON 파싱 오류:\n'+e.message); return; }
if(obj.type!=='ROOT'){ alert('최상위 타입이 ROOT가 아닙니다.'); return; }
FT=obj;
closeFillerImport();
document.getElementById('fillerImportTxt').value='';
const mark=FT.attributes?.mark??'';
const title=FT.attributes?.title??'(제목 없음)';
document.getElementById('fillerTitle').textContent=(mark?mark+' ':'')+title;
renderFillerTemplateView();
parseAndMerge();
}
function clearFiller(){
if(!confirm('템플릿과 입력 내용을 모두 지울까요?')) return;
FT=null; fillerMerged={}; fillerInstCount=0;
document.getElementById('fillerTitle').textContent='템플릿 없음';
document.getElementById('fillerInputText').value='';
document.getElementById('instCount').textContent='';
renderFillerTemplateView();
updateFillerPreview();
}
// ── 수집 섹션 키 추출 ──────────────────────────────────
function getCollectKeys(root){
const keys=new Set();
function walk(n){ for(const c of n.children??[]){ if(c.type==='SECTION'){ if(c.attributes?.collect==='true'){ keys.add((c.attributes?.mark??'')+(c.content??'')); } walk(c); } } }
walk(root);
return keys;
}
// ── 템플릿 구조 표시 ───────────────────────────────────
function renderFillerTemplateView(){
const el=document.getElementById('ftplView');
if(!FT){ el.innerHTML='<span class="ftpl-empty">← 템플릿을 먼저 불러오세요</span>'; return; }
const selective=getCollectKeys(FT).size>0;
function walkSec(sec, depth){
const m=sec.attributes?.mark??'';
const n=sec.content??'';
const pl=sec.attributes?.per_line;
const isCol=sec.attributes?.collect==='true';
const roleHtml=selective?`<span class="ftpl-role ${isCol?'collect':'fixed'}">${isCol?'📥수집':'🔒고정'}</span>`:'';
let html=`<span class="ftpl-sec${isCol?' is-collect':''}">${depth?'<span class="ftpl-arr">▸</span>':''}<span class="ftpl-mark">${esc(m)}</span><span class="ftpl-name">${esc(n)}</span>${roleHtml}${pl?`<span class="ftpl-badge">${esc(pl)}개/줄</span>`:''}</span>`;
for(const c of sec.children??[]) if(c.type==='SECTION') html+=walkSec(c,depth+1);
return html;
}
const tm=FT.attributes?.mark??'';
const tt=FT.attributes?.title??'';
let html=tt?`<span class="ftpl-title"><span class="ftpl-mark">${esc(tm)}</span><span class="ftpl-name">${esc(tt)}</span></span>`:'';
for(const c of FT.children??[]) if(c.type==='SECTION') html+=walkSec(c,0);
el.innerHTML=html;
}
// ── 파싱 & 합치기 ──────────────────────────────────────
function extractSectionMarks(node){
const marks=new Set();
function walk(n){ for(const c of n.children??[]){ if(c.type==='SECTION'){ const m=c.attributes?.mark??''; if(m) marks.add(m); walk(c); } } }
walk(node);
return [...marks];
}
function splitIntoInstances(rawText, titleMark){
if(!titleMark) return rawText.split(/\n{3,}/).map(s=>s.trim()).filter(Boolean);
const lines=rawText.split('\n');
const instances=[]; let current=[];
for(const line of lines){
if(line.trim().startsWith(titleMark)&&current.some(l=>l.trim())){
instances.push(current.join('\n')); current=[];
}
current.push(line);
}
if(current.some(l=>l.trim())) instances.push(current.join('\n'));
return instances;
}
function parseInstance(text, titleMark, sectionMarks){
const sorted=sectionMarks.slice().sort((a,b)=>b.length-a.length);
const result={sections:{}};
let curKey=null;
for(const rawLine of text.split('\n')){
const line=rawLine.trim(); if(!line) continue;
if(titleMark&&line.startsWith(titleMark)) continue;
let matched=false;
for(const mark of sorted){
if(line.startsWith(mark)){
curKey=mark+line.slice(mark.length).trim();
if(!result.sections[curKey]) result.sections[curKey]=[];
matched=true; break;
}
}
if(!matched&&curKey){
result.sections[curKey].push(...line.split(/\s+/).filter(Boolean));
}
}
return result;
}
function parseAndMerge(){
const rawText=document.getElementById('fillerInputText').value;
const instEl=document.getElementById('instCount');
if(!FT||!rawText.trim()){
fillerMerged={}; fillerInstCount=0;
instEl.textContent=''; updateFillerPreview(); return;
}
const titleMark=FT.attributes?.mark??'';
const sectionMarks=extractSectionMarks(FT);
const instances=splitIntoInstances(rawText,titleMark);
fillerInstCount=instances.length;
fillerMerged={};
const collectKeys=getCollectKeys(FT);
const selective=collectKeys.size>0;
for(const text of instances){
const parsed=parseInstance(text,titleMark,sectionMarks);
for(const [key,tokens] of Object.entries(parsed.sections)){
if(selective&&!collectKeys.has(key)) continue;
if(!fillerMerged[key]) fillerMerged[key]=[];
fillerMerged[key].push(...tokens);
}
}
instEl.textContent=instances.length?`(${instances.length}개 양식)`:'';
updateFillerPreview();
}
// ── 텍스트 출력 ────────────────────────────────────────
function mergedToText(){
if(!FT) return '';
const collectKeys=getCollectKeys(FT);
const selective=collectKeys.size>0;
function walkSec(sec){
const mark=sec.attributes?.mark??'';
const content=sec.content??'';
const key=mark+content;
const isCol=selective?(sec.attributes?.collect==='true'):true;
const perLine=parseInt(sec.attributes?.per_line??'0')||0;
let t=mark+content+'\n';
if(isCol){
const tokens=fillerMerged[key]??[];
if(tokens.length){
if(perLine>0){
for(let i=0;i<tokens.length;i+=perLine) t+=tokens.slice(i,i+perLine).join(' ')+'\n';
} else {
t+=tokens.join(' ')+'\n';
}
}
} else {
for(const c of sec.children??[]){
if(c.type==='VALUE'&&c.value!=null&&String(c.value).trim()) t+=String(c.value)+'\n';
}
}
for(const c of sec.children??[]) if(c.type==='SECTION') t+=walkSec(c);
return t;
}
const titleMark=FT.attributes?.mark??'';
const title=FT.attributes?.title??'';
let out=title?`${titleMark}${title}\n`:'';
for(const c of FT.children??[]) if(c.type==='SECTION') out+=walkSec(c);
return out.replace(/\n{3,}/g,'\n\n').trimEnd()+'\n';
}
// ── JSON 출력 ───────────────────────────────────────────
function mergedToJSON(){
if(!FT) return '{}';
const collectKeys=getCollectKeys(FT);
const selective=collectKeys.size>0;
function buildSec(sec){
const mark=sec.attributes?.mark??'';
const content=sec.content??'';
const key=mark+content;
const isCol=selective?(sec.attributes?.collect==='true'):true;
let valChildren;
if(isCol){
const tokens=fillerMerged[key]??[];
valChildren=tokens.map((t,i)=>({id:'mv'+i,type:'VALUE',data_type:'string',value:t}));
} else {
valChildren=(sec.children??[]).filter(c=>c.type==='VALUE');
}
const secChildren=(sec.children??[]).filter(c=>c.type==='SECTION').map(buildSec);
return {...sec, children:[...valChildren,...secChildren]};
}
const merged={...FT, children:(FT.children??[]).map(c=>c.type==='SECTION'?buildSec(c):c)};
return JSON.stringify(merged,null,2);
}
// ── 미리보기 & 공통 ────────────────────────────────────
function updateFillerPreview(){
const el=document.getElementById('fillerOut');
if(!FT||!fillerInstCount){
el.className='preview-text dim';
el.textContent='양식을 붙여넣으면 합치기 결과가 표시됩니다';
return;
}
if(fillerTab==='text'){
el.className='preview-text'; el.textContent=mergedToText();
} else {
el.className='preview-text'; el.innerHTML=highlight(mergedToJSON());
}
}
function setFillerTab(t){
fillerTab=t;
document.querySelectorAll('.ftab').forEach(el=>el.classList.remove('active'));
document.getElementById('ftab-'+t).classList.add('active');
updateFillerPreview();
}
function copyFillerOutput(){
const txt=fillerTab==='text'?mergedToText():mergedToJSON();
navigator.clipboard.writeText(txt).then(()=>{
const btn=document.getElementById('fCopyBtn');
btn.textContent='✅ 복사됨'; btn.classList.add('ok');
setTimeout(()=>{ btn.innerHTML='📋 복사'; btn.classList.remove('ok'); },2000);
});
}
// ════════════════════════════════════════════════════════
// Tab 3: 구조 추출
// ════════════════════════════════════════════════════════
let EX = { nodes:[], title:'', titleMark:'' };
const EX_COLORS = ['#4f6ef7','#7c3aed','#0891b2','#059669','#dc2626'];
function runExtract(){
const raw = document.getElementById('extInputText').value;
const titleMark = document.getElementById('extTitleMark').value||'';
const marks = document.getElementById('extMarks').value.split(',').map(m=>m.trim()).filter(Boolean);
const keepValues = document.getElementById('extKeepValues').checked;
if(!raw.trim()||!marks.length){
EX={nodes:[],title:'',titleMark};
renderExtractTree([]); return;
}
EX = extractStructure(raw, titleMark, marks, keepValues);
renderExtractTree(EX.nodes);
}
function extractStructure(raw, titleMark, marks, keepValues){
const sorted=marks.map((m,i)=>({m,lv:i+1})).sort((a,b)=>b.m.length-a.m.length);
let title='';
const nodes=[], stack=[];
let curNode=null, pending=[];
function findNode(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=findNode([v.node],id); if(f) return f; }
}
return null;
}
function flushPending(){
if(curNode&&keepValues&&pending.length){
const sv=mkScalarVal('string'); sv.data=pending.join('\n').trim();
if(sv.data) curNode.values.push(sv);
}
pending=[];
}
for(const rawLine of raw.split('\n')){
const line=rawLine.trim(); if(!line) continue;
if(titleMark&&line.startsWith(titleMark)){ flushPending(); title=line.slice(titleMark.length).trim(); continue; }
let matched=false;
for(const {m,lv} of sorted){
if(line.startsWith(m)){
flushPending();
const node=mkNode(m, line.slice(m.length).trim());
while(stack.length){
const top=findNode(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=findNode(nodes,stack[stack.length-1].id); if(p) p.values.push(mkNodeVal(node)); }
stack.push({id:node.id,lv});
curNode=node; matched=true; break;
}
}
if(!matched&&curNode&&keepValues) pending.push(line);
}
flushPending();
return {nodes, title, titleMark};
}
function renderExtractTree(nodes){
const el=document.getElementById('extTree');
const countEl=document.getElementById('extCount');
const applyBtn=document.getElementById('extApplyBtn');
if(!nodes||!nodes.length){
el.innerHTML='<div class="ext-node-empty">텍스트를 붙여넣으면<br>구조가 여기 표시됩니다</div>';
countEl.textContent=''; applyBtn.disabled=true; return;
}
let total=0;
function countAll(n){ total++; for(const v of n.values) if(v.type==='node') countAll(v.node); }
for(const n of nodes) countAll(n);
countEl.textContent=`(${total}개 노드)`;
applyBtn.disabled=false;
function renderNode(n, depth){
const color=EX_COLORS[depth%EX_COLORS.length];
const vals=n.values.filter(v=>v.type!=='node');
const kids=n.values.filter(v=>v.type==='node');
let html=`<div class="ext-node" style="margin-left:${depth*18}px">
<div class="ext-node-head" style="border-left-color:${color}">
<span class="cnode-mark">${esc(n.mark||'·')}</span>
<span class="ext-node-name">${esc(n.content||'이름 없음')}</span>
${vals.length?`<span class="ext-node-vals">값 ${vals.length}</span>`:''}
</div>
</div>`;
for(const v of kids) html+=renderNode(v.node, depth+1);
return html;
}
el.innerHTML=nodes.map(n=>renderNode(n,0)).join('');
}
function autoDetectMarks(){
const raw=document.getElementById('extInputText').value;
if(!raw.trim()) return;
const titleMark=document.getElementById('extTitleMark').value||'';
const seen=[], seenSet=new Set();
for(const rawLine of raw.split('\n')){
const line=rawLine.trim(); if(!line) continue;
if(titleMark&&line.startsWith(titleMark)) continue;
const chars=[...line];
let mark=chars[0]||'';
if(!mark||/[\w가-힣a-zA-Z0-9]/.test(mark)) continue;
if(chars[1]&&/[️⃣]/.test(chars[1])) mark+=chars[1];
if(!seenSet.has(mark)){ seenSet.add(mark); seen.push(mark); }
}
if(seen.length){
document.getElementById('extMarks').value=seen.slice(0,5).join(',');
runExtract();
}
}
function applyExtracted(){
if(!EX.nodes||!EX.nodes.length) return;
if(S.nodes.length&&!confirm('현재 Builder 내용을 교체할까요?')) return;
S.titleMark=EX.titleMark||'✅';
S.title=EX.title||'';
S.nodes=JSON.parse(JSON.stringify(EX.nodes));
normalizeState(S);
selectedNodeId=null;
refresh();
switchTab('builder');
}
function clearExtractor(){
document.getElementById('extInputText').value='';
document.getElementById('extMarks').value='';
EX={nodes:[],title:'',titleMark:''};
renderExtractTree([]);
document.getElementById('extCount').textContent='';
}
// ════════════════════════════════════════════════════════
// 탭 전환
// ════════════════════════════════════════════════════════
const TAB_IDS = ['builder', 'filler', 'extractor'];
function switchTab(name){
TAB_IDS.forEach(id => {
document.getElementById('panel-'+id).classList.toggle('hidden', id !== name);
document.getElementById('mtab-'+id).classList.toggle('active', id === name);
});
}
// ════════════════════════════════════════════════════════
// 초기화
// ════════════════════════════════════════════════════════
load();
refresh();
document.getElementById('importModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeImport(); });
document.getElementById('fillerImportModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeFillerImport(); });
document.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeImport(); closeFillerImport(); } });
</script>
</body>
</html>