ui: add content filler tab (Step 3)

- New '내용 채우기' tab with full JSON template import workflow
- Load Builder JSON (slim or full) → parse ROOT/SECTION/VALUE tree
- Render form: each SECTION as a card, VALUE nodes as input fields
  (string → textarea, other types → input)
- Nested sections auto-indent with accent left-border via CSS
- Real-time text/JSON preview as values are typed
- Copy button for filled text or filled JSON output
- fillerToText() mirrors toText() logic using stored FV values
- fillerToJSON() reconstructs full JSON with FV values substituted
- Escape key + backdrop click close filler import modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 12:47:45 +09:00
parent 97e2406044
commit c38d071a86

View File

@@ -194,6 +194,31 @@ input,textarea,button,select{font-family:inherit}
.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-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-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} .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}
</style> </style>
</head> </head>
<body> <body>
@@ -257,10 +282,48 @@ input,textarea,button,select{font-family:inherit}
</div><!-- /panel-builder --> </div><!-- /panel-builder -->
<div id="panel-filler" class="tab-panel hidden"> <div id="panel-filler" class="tab-panel hidden">
<div style="flex:1;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;color:var(--muted)">
<span style="font-size:48px">📋</span> <div class="filler-toolbar">
<p style="font-size:15px;font-weight:600">내용 채우기</p> <span style="font-size:16px">📋</span>
<p style="font-size:12px">Step 3에서 구현 예정</p> <span class="filler-title" id="fillerTitle">템플릿 없음</span>
<button class="hdr-btn" onclick="openFillerImport()">📂 JSON 불러오기</button>
<button class="hdr-btn" onclick="clearFiller()">🗑 지우기</button>
</div>
<div class="filler-main">
<div class="filler-form-wrap" id="fillerForm">
<div class="filler-empty">
<span style="font-size:40px">📋</span>
<p style="font-size:14px;font-weight:600">템플릿이 없습니다</p>
<p style="font-size:12px">위의 📂 JSON 불러오기 버튼으로<br>Builder에서 만든 양식 JSON을 가져오세요</p>
</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 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> </div>
@@ -1046,6 +1109,167 @@ function newDoc(){
S={titleMark:'✅',title:'',nodes:[]}; refresh(); S={titleMark:'✅',title:'',nodes:[]}; refresh();
} }
// ════════════════════════════════════════════════════════
// Tab 2: 내용 채우기
// ════════════════════════════════════════════════════════
let FT = null; // filler template (parsed JSON root)
let FV = {}; // filler values: nodeId → string
let fillerTab = 'text';
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; FV={};
initFillerValues(FT);
closeFillerImport();
document.getElementById('fillerImportTxt').value='';
renderFiller();
}
function initFillerValues(node){
for(const child of node.children??[]){
if(child.type==='VALUE'){
if(!child.id) child.id='fv_'+Math.random().toString(36).slice(2);
FV[child.id]=String(child.value??'');
} else if(child.type==='SECTION'){
if(!child.id) child.id='fs_'+Math.random().toString(36).slice(2);
initFillerValues(child);
}
}
}
function clearFiller(){
if(!confirm('템플릿을 지우고 처음부터 시작할까요?')) return;
FT=null; FV={};
renderFiller();
}
function renderFiller(){
const form=document.getElementById('fillerForm');
const titleEl=document.getElementById('fillerTitle');
const outEl=document.getElementById('fillerOut');
if(!FT){
titleEl.textContent='템플릿 없음';
form.innerHTML=`<div class="filler-empty">
<span style="font-size:40px">📋</span>
<p style="font-size:14px;font-weight:600">템플릿이 없습니다</p>
<p style="font-size:12px">위의 📂 JSON 불러오기 버튼으로<br>Builder에서 만든 양식 JSON을 가져오세요</p>
</div>`;
outEl.className='preview-text dim'; outEl.textContent='내용을 채우면 미리보기가 표시됩니다';
return;
}
const title=FT.attributes?.title??'(제목 없음)';
const mark=FT.attributes?.mark??'';
titleEl.textContent=(mark?mark+' ':'')+title;
let html='';
for(const child of FT.children??[]) if(child.type==='SECTION') html+=renderFillerSection(child);
form.innerHTML=html||'<div class="filler-empty"><p style="font-size:12px">양식에 섹션이 없습니다</p></div>';
updateFillerPreview();
}
function renderFillerSection(sec){
const mark=sec.attributes?.mark??'';
const name=sec.content??'';
let body='';
for(const child of sec.children??[]){
if(child.type==='VALUE'){
const type=child.data_type??'string';
const id=child.id;
const val=FV[id]??'';
body+=`<div class="fval-row">
<span class="fval-lbl">${esc(type)}</span>
${type==='string'
?`<textarea class="fval-inp" rows="2" oninput="FV['${id}']=this.value;updateFillerPreview()">${esc(val)}</textarea>`
:`<input class="fval-inp" value="${esc(val)}" oninput="FV['${id}']=this.value;updateFillerPreview()">`}
</div>`;
} else if(child.type==='SECTION'){
body+=renderFillerSection(child);
}
}
return `<div class="fsec">
<div class="fsec-head${!body?' alone':''}">
<span class="fsec-mark">${esc(mark||'·')}</span>
<span class="fsec-name">${esc(name||'이름 없음')}</span>
</div>
${body?`<div class="fsec-body">${body}</div>`:''}
</div>`;
}
function updateFillerPreview(){
const el=document.getElementById('fillerOut'); if(!FT) return;
if(fillerTab==='text'){
const t=fillerToText();
el.className=t.trim()?'preview-text':'preview-text dim';
el.textContent=t.trim()||'내용을 채우면 미리보기가 표시됩니다';
} else {
el.className='preview-text';
el.innerHTML=highlight(fillerToJSON());
}
}
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'?fillerToText():fillerToJSON();
navigator.clipboard.writeText(txt).then(()=>{
const btn=document.getElementById('fCopyBtn');
btn.textContent='✅ 복사됨'; btn.classList.add('ok');
setTimeout(()=>{ btn.innerHTML='📋 복사'; btn.classList.remove('ok'); },2000);
});
}
function fillerToText(){
if(!FT) return '';
function rSec(sec){
const mark=sec.attributes?.mark??'';
const content=sec.content??'';
const prefix=sec.prefix??'';
const suffix=sec.suffix??'';
const nl=sec.nameNewline!==false?'\n':'';
let t=prefix+mark+content;
let first=true;
for(const child of sec.children??[]){
if(child.type==='SECTION'){
t+=rSec(child); first=false;
} else if(child.type==='VALUE'){
const val=FV[child.id]??'';
if(val.trim()){ t+=(first?nl:'\n')+val; first=false; }
}
}
t+=suffix;
return t;
}
const title=FT.attributes?.title??'';
const titleMark=FT.attributes?.mark??'';
let out=title?`${titleMark}${title}\n`:'';
for(const child of FT.children??[]) if(child.type==='SECTION') out+=rSec(child);
return out.replace(/\n{3,}/g,'\n\n').trimEnd()+'\n';
}
function fillerToJSON(){
if(!FT) return '{}';
function fill(node){
const r={...node};
r.children=(node.children??[]).map(child=>{
if(child.type==='VALUE') return {...child, value:FV[child.id]??child.value??null};
if(child.type==='SECTION') return fill(child);
return child;
});
return r;
}
return JSON.stringify(fill(FT),null,2);
}
// ════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════
// 탭 전환 // 탭 전환
// ════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════
@@ -1063,7 +1287,8 @@ function switchTab(name){
load(); load();
refresh(); refresh();
document.getElementById('importModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeImport(); }); document.getElementById('importModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeImport(); });
document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeImport(); }); document.getElementById('fillerImportModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeFillerImport(); });
document.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeImport(); closeFillerImport(); } });
</script> </script>
</body> </body>
</html> </html>