ui: redesign Tab 2 as text-paste form merger (Option A)

Workflow:
  1. Load template JSON (defines section structure)
  2. Paste multiple text-format forms into textarea
  3. Parser splits by title mark, collects tokens per section key
  4. Merged output in text or JSON format

Key changes:
- splitIntoInstances(): split at each title-mark line boundary
- parseInstance(): match section headers by mark+name, tokenize value lines
- mergedToText(): walk template tree, output tokens with per_line chunking
- mergedToJSON(): reconstruct ROOT/SECTION/VALUE tree with merged tokens
- Template view: compact inline chip display of section structure
- Instance counter badge shows how many forms were detected
- JSON export: each token becomes a separate VALUE node (data_type:string)
- per_line attribute on SECTION controls line wrapping in text output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 14:11:59 +09:00
parent c38d071a86
commit 5a86d52813

View File

@@ -219,6 +219,24 @@ input,textarea,button,select{font-family:inherit}
.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)}
</style>
</head>
<body>
@@ -286,18 +304,29 @@ input,textarea,button,select{font-family:inherit}
<div class="filler-toolbar">
<span style="font-size:16px">📋</span>
<span class="filler-title" id="fillerTitle">템플릿 없음</span>
<button class="hdr-btn" onclick="openFillerImport()">📂 JSON 불러오기</button>
<button class="hdr-btn" onclick="clearFiller()">🗑 지우기</button>
<button class="hdr-btn" onclick="openFillerImport()">📂 템플릿 불러오기</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 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">
@@ -308,9 +337,10 @@ input,textarea,button,select{font-family:inherit}
<button class="copy-btn" id="fCopyBtn" onclick="copyFillerOutput()">📋 복사</button>
</div>
<div class="preview-body">
<pre class="preview-text dim" id="fillerOut">내용을 채우면 미리보기가 표시됩니다</pre>
<pre class="preview-text dim" id="fillerOut">양식을 붙여넣으면 합치기 결과가 표시됩니다</pre>
</div>
</div>
</div>
</div><!-- /panel-filler -->
@@ -1110,11 +1140,12 @@ function newDoc(){
}
// ════════════════════════════════════════════════════════
// Tab 2: 내용 채우기
// Tab 2: 양식 합치기 (텍스트 붙여넣기 방식)
// ════════════════════════════════════════════════════════
let FT = null; // filler template (parsed JSON root)
let FV = {}; // filler values: nodeId → string
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') }
@@ -1124,91 +1155,169 @@ function doLoadFillerTemplate(){
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);
FT=obj;
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);
}
}
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; 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>';
if(!confirm('템플릿과 입력 내용을 모두 지울까요?')) return;
FT=null; fillerMerged={}; fillerInstCount=0;
document.getElementById('fillerTitle').textContent='템플릿 없음';
document.getElementById('fillerInputText').value='';
document.getElementById('instCount').textContent='';
renderFillerTemplateView();
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);
// ── 템플릿 구조 표시 ───────────────────────────────────
function renderFillerTemplateView(){
const el=document.getElementById('ftplView');
if(!FT){ el.innerHTML='<span class="ftpl-empty">← 템플릿을 먼저 불러오세요</span>'; return; }
function walkSec(sec, depth){
const m=sec.attributes?.mark??'';
const n=sec.content??'';
const pl=sec.attributes?.per_line;
let html=`<span class="ftpl-sec">${depth?'<span class="ftpl-arr">▸</span>':''}<span class="ftpl-mark">${esc(m)}</span><span class="ftpl-name">${esc(n)}</span>${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;
}
}
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>`;
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 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()||'내용을 채우면 미리보기가 표시됩니다';
// ── 파싱 & 합치기 ──────────────────────────────────────
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={};
for(const text of instances){
const parsed=parseInstance(text,titleMark,sectionMarks);
for(const [key,tokens] of Object.entries(parsed.sections)){
if(!fillerMerged[key]) fillerMerged[key]=[];
fillerMerged[key].push(...tokens);
}
}
instEl.textContent=instances.length?`(${instances.length}개 양식)`:'';
updateFillerPreview();
}
// ── 텍스트 출력 ────────────────────────────────────────
function mergedToText(){
if(!FT) return '';
function walkSec(sec){
const mark=sec.attributes?.mark??'';
const content=sec.content??'';
const key=mark+content;
const perLine=parseInt(sec.attributes?.per_line??'0')||0;
const tokens=fillerMerged[key]??[];
let t=mark+content+'\n';
if(tokens.length){
if(perLine>0){
for(let i=0;i<tokens.length;i+=perLine) t+=tokens.slice(i,i+perLine).join(' ')+'\n';
} else {
el.className='preview-text';
el.innerHTML=highlight(fillerToJSON());
t+=tokens.join(' ')+'\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 '{}';
function buildSec(sec){
const mark=sec.attributes?.mark??'';
const content=sec.content??'';
const key=mark+content;
const tokens=fillerMerged[key]??[];
const valChildren=tokens.map((t,i)=>({
id:'mv'+i,type:'VALUE',data_type:'string',value:t
}));
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());
}
}
@@ -1220,7 +1329,7 @@ function setFillerTab(t){
}
function copyFillerOutput(){
const txt=fillerTab==='text'?fillerToText():fillerToJSON();
const txt=fillerTab==='text'?mergedToText():mergedToJSON();
navigator.clipboard.writeText(txt).then(()=>{
const btn=document.getElementById('fCopyBtn');
btn.textContent='✅ 복사됨'; btn.classList.add('ok');
@@ -1228,48 +1337,6 @@ function copyFillerOutput(){
});
}
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);
}
// ════════════════════════════════════════════════════════
// 탭 전환
// ════════════════════════════════════════════════════════