Files
CTStruct/ui/builder.html
gm bbee99a225 feat(ui): add standalone HTML form builder
- 좌측: 시각적 편집기 (섹션/이름그룹 추가·삭제·순서변경)
- 우측: 텍스트/JSON slim/JSON full 실시간 미리보기 + 복사
- 구분자 자유 설정 (이모지·문자열·임의 개수 레벨)
- 텍스트 불러오기 (EmojiMarkupParser JS 포트)
- localStorage 자동저장

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:41:50 +09:00

746 lines
33 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;
--amber:#d97706;
--l1:#4f6ef7;--l1-bg:#eef0ff;
--l2:#8b5cf6;--l2-bg:#f5f0ff;
--l3:#d97706;--l3-bg:#fffbeb;
--r:8px;--sh:0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.05);
--sh2:0 4px 12px rgba(0,0,0,.08);
/* dark */
--dk:#1e1e2e;--dk2:#181825;--dk3:#313244;--dk4:#45475a;
--dt:#cdd6f4;--dt2:#bac2de;--dt3:#6c7086;
}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;flex-direction:column;font-size:14px}
/* ── 헤더 ─────────────────────────────────────────────────── */
.app-header{
background:var(--surface);border-bottom:1px solid var(--border);
padding:10px 16px;display:flex;align-items:center;gap:10px;
box-shadow:var(--sh);z-index:10;flex-shrink:0
}
.app-header h1{font-size:15px;font-weight:600}
.badge{font-size:10px;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:99px;font-weight:600}
.hdr-spacer{flex:1}
.hdr-btn{
display:flex;align-items:center;gap:5px;
font-size:12px;padding:5px 12px;border-radius:6px;cursor:pointer;
border:1px solid var(--border);background:var(--surface);color:var(--muted);
transition:all .15s;font-weight:500
}
.hdr-btn:hover{background:var(--surface-alt);color:var(--text);border-color:var(--border-focus)}
/* ── 메인 레이아웃 ────────────────────────────────────────── */
.app-body{display:flex;flex:1;overflow:hidden}
/* ── 빌더 패널 ────────────────────────────────────────────── */
.builder-panel{
width:440px;min-width:320px;background:var(--surface);
border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden
}
.panel-label{
padding:10px 16px 8px;font-size:10px;font-weight:700;
text-transform:uppercase;letter-spacing:.08em;color:var(--muted);
border-bottom:1px solid var(--border);flex-shrink:0;background:var(--surface-alt)
}
.builder-scroll{flex:1;overflow-y:auto;padding:14px}
.builder-scroll::-webkit-scrollbar{width:5px}
.builder-scroll::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
/* ── 설정 카드 ───────────────────────────────────────────── */
.config-card{
background:var(--surface-alt);border:1px solid var(--border);
border-radius:var(--r);padding:11px 13px;margin-bottom:14px
}
.config-card-title{
font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;
color:var(--muted);margin-bottom:10px;display:flex;align-items:center;gap:5px;cursor:pointer
}
.config-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.config-row:last-child{margin-bottom:0}
.config-lbl{font-size:12px;color:var(--muted);width:48px;flex-shrink:0}
.mark-input{
width:56px;text-align:center;font-size:15px;border:1px solid var(--border);
border-radius:6px;padding:3px 6px;background:var(--surface);cursor:text
}
.marks-wrap{display:flex;flex-wrap:wrap;gap:5px;flex:1}
.mark-chip{
display:flex;align-items:center;gap:3px;background:var(--surface);
border:1px solid var(--border);border-radius:6px;padding:2px 5px
}
.mark-chip .lvl{font-size:9px;color:var(--muted);font-weight:700}
.mark-chip input{width:46px;font-size:14px;border:none;outline:none;text-align:center;background:transparent}
.mark-chip .rm{cursor:pointer;color:var(--muted);font-size:11px;padding:0 2px;line-height:1}
.mark-chip .rm:hover{color:var(--red)}
.btn-add-mark{
font-size:11px;padding:3px 9px;background:var(--accent-bg);color:var(--accent);
border:1px dashed var(--accent);border-radius:6px;cursor:pointer;font-weight:600;align-self:center
}
.btn-add-mark:hover{background:var(--accent);color:#fff;border-style:solid}
/* ── 제목 입력 ────────────────────────────────────────────── */
.field-label{font-size:11px;font-weight:600;color:var(--muted);margin-bottom:5px;letter-spacing:.04em;text-transform:uppercase}
.title-wrap{
display:flex;align-items:center;gap:7px;background:var(--surface-alt);
border:1.5px solid var(--border);border-radius:var(--r);padding:7px 11px;
margin-bottom:14px;transition:border-color .15s
}
.title-wrap:focus-within{border-color:var(--border-focus);background:var(--surface)}
.title-mark{font-size:16px;flex-shrink:0}
.title-wrap input{border:none;outline:none;font-size:13px;flex:1;background:transparent;font-weight:500}
.title-wrap input::placeholder{color:var(--muted);font-weight:400}
/* ── 섹션 추가 버튼 ───────────────────────────────────────── */
.btn-add-sec{
display:flex;align-items:center;justify-content:center;gap:7px;
width:100%;padding:9px;background:var(--accent-bg);color:var(--accent);
border:1.5px dashed var(--accent);border-radius:var(--r);cursor:pointer;
font-size:12px;font-weight:600;margin-bottom:12px;transition:all .15s
}
.btn-add-sec:hover{background:var(--accent);color:#fff;border-style:solid}
/* ── 섹션 카드 ────────────────────────────────────────────── */
.sec-list{display:flex;flex-direction:column;gap:9px}
.sec-card{
border:1.5px solid var(--border);border-radius:var(--r);
background:var(--surface);box-shadow:var(--sh);overflow:visible
}
.sec-card[data-level="1"]{border-left:3px solid var(--l1)}
.sec-card[data-level="2"]{border-left:3px solid var(--l2);margin-left:14px}
.sec-card[data-level="3"]{border-left:3px solid var(--l3);margin-left:28px}
.sec-head{
display:flex;align-items:center;gap:7px;padding:7px 9px;
background:var(--surface-alt);border-bottom:1px solid var(--border)
}
.sec-mark{font-size:13px;flex-shrink:0}
.sec-name{
flex:1;border:none;outline:none;font-size:13px;font-weight:500;
background:transparent;min-width:0
}
.sec-name::placeholder{color:var(--muted);font-weight:400}
.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)}
.sec-body{padding:9px}
/* ── 이름 그룹 ────────────────────────────────────────────── */
.grp{border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:7px;background:var(--surface-alt)}
.grp-head{display:flex;align-items:center;gap:6px;margin-bottom:6px}
.grp-cat-lbl{font-size:10px;color:var(--muted);flex-shrink:0;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
.grp-cat{
flex:1;font-size:12px;border:1px solid var(--border);
border-radius:4px;padding:3px 7px;background:var(--surface)
}
.grp-cat:focus{outline:none;border-color:var(--border-focus)}
.grp-cat::placeholder{color:var(--muted)}
.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-hint{font-size:10px;color:var(--muted);margin-top:3px}
/* ── 섹션 하단 액션 ──────────────────────────────────────── */
.sec-actions{display:flex;gap:5px;flex-wrap:wrap;padding-top:2px}
.sBtn{
font-size:11px;padding:3px 9px;border-radius:4px;border:1px solid;
cursor:pointer;font-weight:600;transition:all .12s
}
.sBtn.blue{background:var(--accent-bg);color:var(--accent);border-color:var(--accent)}
.sBtn.blue:hover{background:var(--accent);color:#fff}
.sBtn.green{background:var(--green-bg);color:var(--green);border-color:var(--green)}
.sBtn.green:hover{background:var(--green);color:#fff}
.children-list{display:flex;flex-direction:column;gap:8px;margin-top:9px}
/* ── 미리보기 패널 ────────────────────────────────────────── */
.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}
/* JSON 색상 */
.j-key{color:#89b4fa}
.j-str{color:#a6e3a1}
.j-num{color:#fab387}
.j-bool{color:#f38ba8}
.j-null{color:#f38ba8;font-style:italic}
/* ── 모달 ──────────────────────────────────────────────────── */
.overlay{
position:fixed;inset:0;background:rgba(0,0,0,.45);
display:none;align-items:center;justify-content:center;z-index:200
}
.overlay.open{display:flex}
.modal{
background:var(--surface);border-radius:12px;padding:20px;
width:500px;max-width:92vw;box-shadow:0 20px 60px rgba(0,0,0,.25)
}
.modal h2{font-size:15px;font-weight:700;margin-bottom:6px}
.modal p{font-size:12px;color:var(--muted);margin-bottom:10px}
.modal textarea{
width:100%;height:200px;font-family:monospace;font-size:12px;
border:1.5px solid var(--border);border-radius:6px;padding:9px;
resize:vertical;line-height:1.6
}
.modal textarea:focus{outline:none;border-color:var(--border-focus)}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
.mBtn{font-size:13px;padding:7px 16px;border-radius:6px;border:1px solid;cursor:pointer;font-weight:600}
.mBtn.ok{background:var(--accent);color:#fff;border-color:var(--accent)}
.mBtn.ok:hover{background:#3a5ce0}
.mBtn.cancel{background:var(--surface);color:var(--muted);border-color:var(--border)}
.mBtn.cancel:hover{background:var(--surface-alt)}
/* ── 분리선 ────────────────────────────────────────────────── */
.sep{height:1px;background:var(--border);margin:12px 0}
input,textarea,button{font-family:inherit}
</style>
</head>
<body>
<header class="app-header">
<span style="font-size:18px">🏗️</span>
<h1>CTStruct 양식 빌더</h1>
<span class="badge">v0.1</span>
<div class="hdr-spacer"></div>
<button class="hdr-btn" onclick="openImport()">📥 불러오기</button>
<button class="hdr-btn" onclick="newDoc()">🗒 새 문서</button>
</header>
<div class="app-body">
<!-- ── 빌더 패널 ──────────────────────────────────── -->
<div class="builder-panel">
<div class="panel-label">✏️ 편집기</div>
<div class="builder-scroll">
<!-- 구분자 설정 -->
<div class="config-card">
<div class="config-card-title">⚙️ 구분자 설정</div>
<div class="config-row">
<span class="config-lbl">제목</span>
<input id="cfgTitle" class="mark-input" value="✅" oninput="syncConfig()">
</div>
<div class="config-row" style="align-items:flex-start">
<span class="config-lbl" style="padding-top:5px">섹션</span>
<div class="marks-wrap" id="marksList"></div>
<button class="btn-add-mark" onclick="addMark()">+ 레벨</button>
</div>
</div>
<!-- 문서 제목 -->
<div class="field-label">문서 제목</div>
<div class="title-wrap">
<span class="title-mark" id="titleMarkBadge"></span>
<input id="docTitle" placeholder="제목을 입력하세요" oninput="updatePreview()">
</div>
<div class="sep"></div>
<button class="btn-add-sec" onclick="addSection(null,1)"> 섹션 추가</button>
<div class="sec-list" id="secList"></div>
</div>
</div>
<!-- ── 미리보기 패널 ──────────────────────────────── -->
<div class="preview-panel">
<div class="preview-head">
<div class="tabs">
<button class="tab active" id="tab-text" onclick="setTab('text')">텍스트</button>
<button class="tab" id="tab-json" onclick="setTab('json')">JSON slim</button>
<button class="tab" id="tab-json-full"onclick="setTab('json-full')">JSON full</button>
</div>
<div class="p-spacer"></div>
<button class="copy-btn" id="copyBtn" onclick="copyOutput()">📋 복사</button>
</div>
<div class="preview-body">
<pre class="preview-text" id="previewOut"></pre>
</div>
</div>
</div>
<!-- ── 불러오기 모달 ─────────────────────────────────── -->
<div class="overlay" id="importModal">
<div class="modal">
<h2>📥 텍스트 불러오기</h2>
<p>기존 양식 텍스트를 붙여넣으면 편집기로 불러옵니다. 현재 구분자 설정을 기준으로 파싱합니다.</p>
<textarea id="importTxt" placeholder="✅예시 양식&#10;&#10;🔹1번 항목&#10;홍길동 홍길순&#10;&#10;🔹2번 항목&#10;🔸소항목&#10;(건강) 홍길찬"></textarea>
<div class="modal-actions">
<button class="mBtn cancel" onclick="closeImport()">취소</button>
<button class="mBtn ok" onclick="doImport()">불러오기</button>
</div>
</div>
</div>
<script>
// ════════════════════════════════════════════════════════════
// 상태
// ════════════════════════════════════════════════════════════
let S = {
cfg: { titleMark:'✅', marks:['🔹','🔸'] },
title: '예시 양식',
sections: [
{ id:id(), lv:1, name:'1번 항목',
groups:[{id:id(),cat:'',names:'홍길동 홍길순 홍길영 홍길준\n홍길호 홍길윤'}],
children:[] },
{ id:id(), lv:1, name:'2번 항목', groups:[], children:[
{ id:id(), lv:2, name:'2-1 항목', groups:[{id:id(),cat:'건강',names:'홍길찬 홍길만'}], children:[] },
{ id:id(), lv:2, name:'2-2 항목', groups:[{id:id(),cat:'직장',names:'홍길주'}], children:[] }
]}
]
};
let currentTab = 'text';
function id(){ return Math.random().toString(36).slice(2,9) }
function esc(s){ return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') }
// ── localStorage 자동저장 ────────────────────────────────────
function save(){
try{ localStorage.setItem('ctstruct-builder', JSON.stringify(S)) }catch(_){}
}
function load(){
try{
const raw = localStorage.getItem('ctstruct-builder');
if(raw) S = JSON.parse(raw);
}catch(_){}
}
// ════════════════════════════════════════════════════════════
// 설정
// ════════════════════════════════════════════════════════════
function syncConfig(){
S.cfg.titleMark = document.getElementById('cfgTitle').value || '✅';
document.getElementById('titleMarkBadge').textContent = S.cfg.titleMark;
renderMarks();
renderSections();
updatePreview();
save();
}
function addMark(){
S.cfg.marks.push('');
renderMarks();
renderSections();
updatePreview();
save();
}
function removeMark(i){
if(S.cfg.marks.length<=1) return;
S.cfg.marks.splice(i,1);
renderMarks();
renderSections();
updatePreview();
save();
}
function setMark(i,v){
S.cfg.marks[i]=v;
renderSections();
updatePreview();
save();
}
function renderMarks(){
document.getElementById('marksList').innerHTML = S.cfg.marks.map((m,i)=>`
<div class="mark-chip">
<span class="lvl">L${i+1}</span>
<input value="${esc(m)}" placeholder="마커" oninput="setMark(${i},this.value)">
${S.cfg.marks.length>1?`<span class="rm" onclick="removeMark(${i})">✕</span>`:''}
</div>`).join('');
}
// ════════════════════════════════════════════════════════════
// 섹션 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 removeSec(list, id){ return list.filter(s=>s.id!==id).map(s=>({...s,children:removeSec(s.children,id)})) }
function addSection(parentId, lv){
const ns={id:id(),lv,name:'',groups:[],children:[]};
if(!parentId) S.sections.push(ns);
else{ const p=findSec(S.sections,parentId); if(p) p.children.push(ns); }
renderSections();
updatePreview();
save();
setTimeout(()=>{ const el=document.querySelector(`[data-sid="${ns.id}"] .sec-name`); 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();
}
function updateName(id,v){ const s=findSec(S.sections,id); if(s) s.name=v; updatePreview(); save(); }
// ════════════════════════════════════════════════════════════
// 그룹 CRUD
// ════════════════════════════════════════════════════════════
function addGroup(sid){
const s=findSec(S.sections,sid); if(!s) return;
const ng={id:id(),cat:'',names:''};
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,v){
const s=findSec(S.sections,sid); if(!s) return;
const g=s.groups.find(g=>g.id===gid); if(!g) return;
g[field]=v; updatePreview(); save();
}
// ════════════════════════════════════════════════════════════
// 렌더러 빌더
// ════════════════════════════════════════════════════════════
function renderSec(sec, idx, totalInParent){
const mark = S.cfg.marks[sec.lv-1] ?? '';
const maxLv = S.cfg.marks.length;
const canChild = sec.lv < maxLv;
const grpsHtml = sec.groups.map((g,gi)=>`
<div class="grp" data-gid="${g.id}">
<div class="grp-head">
<span class="grp-cat-lbl">카테고리</span>
<input class="grp-cat" placeholder="없으면 빈칸"
value="${esc(g.cat)}"
oninput="updateGroup('${sec.id}','${g.id}','cat',this.value)">
<button class="iBtn up" onclick="moveGroup('${sec.id}','${g.id}',-1)" title="위로">↑</button>
<button class="iBtn dn" onclick="moveGroup('${sec.id}','${g.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="deleteGroup('${sec.id}','${g.id}')" title="삭제">✕</button>
</div>
<textarea class="grp-names"
placeholder="이름 입력 (공백/줄바꿈으로 구분)"
oninput="updateGroup('${sec.id}','${g.id}','names',this.value)">${esc(g.names)}</textarea>
<div class="grp-hint">스페이스·줄바꿈·쉼표로 이름을 구분합니다</div>
</div>`).join('');
const childrenHtml = sec.children.map((c,ci)=>renderSec(c,ci,sec.children.length)).join('');
return `
<div class="sec-card" data-level="${sec.lv}" data-sid="${sec.id}">
<div class="sec-head">
<span class="sec-mark">${esc(mark)}</span>
<input class="sec-name" placeholder="섹션 이름"
value="${esc(sec.name)}"
oninput="updateName('${sec.id}',this.value)">
<button class="iBtn up" onclick="moveSec('${sec.id}',-1)" title="위로">↑</button>
<button class="iBtn dn" onclick="moveSec('${sec.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="deleteSection('${sec.id}')" title="삭제">✕</button>
</div>
<div class="sec-body">
${grpsHtml}
<div class="sec-actions">
<button class="sBtn blue" onclick="addGroup('${sec.id}')"> 이름 그룹</button>
${canChild?`<button class="sBtn green" onclick="addSection('${sec.id}',${sec.lv+1})"> 소항목 (L${sec.lv+1})</button>`:''}
</div>
${childrenHtml.length?`<div class="children-list">${childrenHtml}</div>`:''}
</div>
</div>`;
}
function renderSections(){
renderMarks();
document.getElementById('titleMarkBadge').textContent = S.cfg.titleMark;
document.getElementById('cfgTitle').value = S.cfg.titleMark;
document.getElementById('docTitle').value = S.title;
document.getElementById('secList').innerHTML = S.sections.map((s,i)=>renderSec(s,i,S.sections.length)).join('');
}
// ════════════════════════════════════════════════════════════
// 텍스트 변환
// ════════════════════════════════════════════════════════════
function parseNames(raw){
return raw.trim().split(/[\s,\n]+/).filter(n=>n.length>0);
}
function toText(){
const {titleMark,marks} = S.cfg;
const lines=[];
const title = S.title.trim();
if(title){ lines.push(`${titleMark}${title}`); lines.push(''); }
function renderSec(sec){
const mark = marks[sec.lv-1]??'';
lines.push(`${mark}${sec.name}`);
for(const g of sec.groups){
const ns=parseNames(g.names);
if(!ns.length) continue;
const pre=g.cat.trim()?`(${g.cat.trim()}) `:'';
lines.push(pre+ns.join(' '));
lines.push('');
}
for(const c of sec.children) renderSec(c);
}
for(const s of S.sections) renderSec(s);
return lines.join('\n').replace(/\n{3,}/g,'\n\n').trimEnd()+'\n';
}
// ════════════════════════════════════════════════════════════
// JSON 변환
// ════════════════════════════════════════════════════════════
function stripNulls(obj){
if(Array.isArray(obj)) return obj.map(stripNulls);
if(obj&&typeof obj==='object'){
const r={};
for(const [k,v] of Object.entries(obj)){
if(v===null) continue;
if(k==='data_type'&&v==='unset') continue;
if(k==='attributes'&&typeof v==='object'&&Object.keys(v).length===0) continue;
r[k]=stripNulls(v);
}
return r;
}
return obj;
}
function buildNode(overrides){
return { content:null,header:null,footer:null,prefix:null,suffix:null,
attributes:{},data_type:'unset',value:null,children:[], ...overrides };
}
function toJSON(slim=false){
function ser(sec){
return buildNode({
id:sec.id, type:'SECTION', content:sec.name,
attributes:{level:sec.lv},
children:[
...sec.groups.map(g=>{
const ns=parseNames(g.names);
return buildNode({
id:g.id, type:'LIST',
attributes:g.cat.trim()?{category:g.cat.trim()}:{},
children:ns.map(n=>buildNode({id:id(),type:'LIST_ITEM',content:n}))
});
}),
...sec.children.map(ser)
]
});
}
const root=buildNode({
id:'root', type:'ROOT',
attributes:S.title.trim()?{title:S.title.trim()}:{},
children:S.sections.map(ser)
});
const data=slim?stripNulls(root):root;
return JSON.stringify(data,null,2);
}
// ════════════════════════════════════════════════════════════
// JSON 구문 강조
// ════════════════════════════════════════════════════════════
function highlight(jsonStr){
return jsonStr
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/("(?:[^"\\]|\\.)*")(\s*:)/g,'<span class="j-key">$1</span>$2')
.replace(/:\s*("(?:[^"\\]|\\.)*")/g,': <span class="j-str">$1</span>')
.replace(/:\s*(-?\d+\.?\d*)/g,': <span class="j-num">$1</span>')
.replace(/:\s*(true|false)/g,': <span class="j-bool">$1</span>')
.replace(/:\s*(null)/g,': <span class="j-null">$1</span>');
}
// ════════════════════════════════════════════════════════════
// 미리보기 업데이트
// ════════════════════════════════════════════════════════════
function setTab(t){
currentTab=t;
document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));
document.getElementById('tab-'+t).classList.add('active');
updatePreview();
}
function updatePreview(){
S.title = document.getElementById('docTitle')?.value ?? '';
const el = document.getElementById('previewOut');
let content='', html=false;
if(currentTab==='text'){
content=toText();
if(!content.trim()){ el.innerHTML=''; el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; }
el.className='preview-text'; el.textContent=content;
} else {
const slim=(currentTab==='json');
content=toJSON(slim);
if(!content.trim()){ el.innerHTML=''; el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; }
el.className='preview-text'; el.innerHTML=highlight(content); html=true;
}
}
// ════════════════════════════════════════════════════════════
// 복사
// ════════════════════════════════════════════════════════════
function copyOutput(){
let content='';
if(currentTab==='text') content=toText();
else if(currentTab==='json') content=toJSON(true);
else content=toJSON(false);
navigator.clipboard.writeText(content).then(()=>{
const btn=document.getElementById('copyBtn');
btn.textContent='✅ 복사됨'; btn.classList.add('ok');
setTimeout(()=>{ btn.innerHTML='📋 복사'; btn.classList.remove('ok') },2000);
});
}
// ════════════════════════════════════════════════════════════
// 불러오기 파서 (EmojiMarkupParser JS 포트)
// ════════════════════════════════════════════════════════════
function openImport(){ document.getElementById('importModal').classList.add('open') }
function closeImport(){ document.getElementById('importModal').classList.remove('open') }
function doImport(){
const raw=document.getElementById('importTxt').value.trim();
if(!raw) return;
const titleMark = S.cfg.titleMark;
const sortedMarks=[...S.cfg.marks.map((m,i)=>({m,lv:i+1}))].sort((a,b)=>b.m.length-a.m.length);
const reCat=/^\(([^)]+)\)\s*(.*)/;
let title='';
const sections=[];
const secStack=[]; // {id, lv} stack
let pend=null; // {parentId, cat, names[]}
function flush(){
if(!pend||!pend.names.length){ pend=null; return; }
const parent = pend.parentId ? findSec(sections, pend.parentId) : null;
const grp={id:id(),cat:pend.cat||'',names:pend.names.join(' ')};
if(parent) parent.groups.push(grp);
pend=null;
}
function topSec(){ return secStack.length? secStack[secStack.length-1] : null }
for(const rawLine of raw.split('\n')){
const line=rawLine.trim();
if(!line){ flush(); continue; }
if(line.startsWith(titleMark)){ flush(); title=line.slice(titleMark.length).trim(); continue; }
let matched=false;
for(const {m,lv} of sortedMarks){
if(line.startsWith(m)){
flush();
const name=line.slice(m.length).trim();
const sec={id:id(),lv,name,groups:[],children:[]};
// 스택 정리: lv 이상인 항목 pop
while(secStack.length){
const top=findSec(sections, secStack[secStack.length-1].id);
if(top&&top.lv>=lv) secStack.pop(); else break;
}
if(!secStack.length) sections.push(sec);
else { const p=findSec(sections,secStack[secStack.length-1].id); if(p) p.children.push(sec); }
secStack.push({id:sec.id,lv});
matched=true; break;
}
}
if(matched) continue;
const cm=reCat.exec(line);
if(cm){
flush();
pend={parentId:topSec()?.id||null, 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:topSec()?.id||null, cat:'', names:[]};
pend.names.push(...line.split(/[\s,]+/).filter(Boolean));
}
flush();
S.title=title; S.sections=sections;
renderSections(); updatePreview(); save(); closeImport();
document.getElementById('importTxt').value='';
}
// ════════════════════════════════════════════════════════════
// 새 문서
// ════════════════════════════════════════════════════════════
function newDoc(){
if(!confirm('현재 내용을 모두 지우고 새 문서를 시작할까요?')) return;
S.title=''; S.sections=[];
renderSections(); updatePreview(); save();
}
// ════════════════════════════════════════════════════════════
// 초기화
// ════════════════════════════════════════════════════════════
load();
renderSections();
updatePreview();
// 모달 닫기
document.getElementById('importModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeImport() });
document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeImport() });
</script>
</body>
</html>