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:
235
ui/builder.html
235
ui/builder.html
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user