feat(ui): add standalone HTML form builder
- 좌측: 시각적 편집기 (섹션/이름그룹 추가·삭제·순서변경) - 우측: 텍스트/JSON slim/JSON full 실시간 미리보기 + 복사 - 구분자 자유 설정 (이모지·문자열·임의 개수 레벨) - 텍스트 불러오기 (EmojiMarkupParser JS 포트) - localStorage 자동저장 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
745
ui/builder.html
Normal file
745
ui/builder.html
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
<!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="✅예시 양식 🔹1번 항목 홍길동 홍길순 🔹2번 항목 🔸소항목 (건강) 홍길찬"></textarea>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="mBtn cancel" onclick="closeImport()">취소</button>
|
||||||
|
<button class="mBtn ok" onclick="doImport()">불러오기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 상태
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') }
|
||||||
|
|
||||||
|
// ── 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||||
|
.replace(/("(?:[^"\\]|\\.)*")(\s*:)/g,'<span class="j-key">$1</span>$2')
|
||||||
|
.replace(/:\s*("(?:[^"\\]|\\.)*")/g,': <span class="j-str">$1</span>')
|
||||||
|
.replace(/:\s*(-?\d+\.?\d*)/g,': <span class="j-num">$1</span>')
|
||||||
|
.replace(/:\s*(true|false)/g,': <span class="j-bool">$1</span>')
|
||||||
|
.replace(/:\s*(null)/g,': <span class="j-null">$1</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 미리보기 업데이트
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
function setTab(t){
|
||||||
|
currentTab=t;
|
||||||
|
document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));
|
||||||
|
document.getElementById('tab-'+t).classList.add('active');
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview(){
|
||||||
|
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>
|
||||||
Reference in New Issue
Block a user