diff --git a/ui/builder.html b/ui/builder.html index 6c0c772..d9b0dc1 100644 --- a/ui/builder.html +++ b/ui/builder.html @@ -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)} @@ -286,18 +304,29 @@ input,textarea,button,select{font-family:inherit}
📋 템플릿 없음 - - + +
-
-
- 📋 -

템플릿이 없습니다

-

위의 📂 JSON 불러오기 버튼으로
Builder에서 만든 양식 JSON을 가져오세요

+ +
+ +
+ ← 템플릿을 먼저 불러오세요 +
+ +
+
+ 양식 붙여넣기 + +
+
+
@@ -308,9 +337,10 @@ input,textarea,button,select{font-family:inherit}
-
내용을 채우면 미리보기가 표시됩니다
+
양식을 붙여넣으면 합치기 결과가 표시됩니다
+
@@ -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=`
- 📋 -

템플릿이 없습니다

-

위의 📂 JSON 불러오기 버튼으로
Builder에서 만든 양식 JSON을 가져오세요

-
`; - 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||'

양식에 섹션이 없습니다

'; + 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+=`
- ${esc(type)} - ${type==='string' - ?`` - :``} -
`; - } else if(child.type==='SECTION'){ - body+=renderFillerSection(child); - } +// ── 템플릿 구조 표시 ─────────────────────────────────── +function renderFillerTemplateView(){ + const el=document.getElementById('ftplView'); + if(!FT){ el.innerHTML='← 템플릿을 먼저 불러오세요'; return; } + function walkSec(sec, depth){ + const m=sec.attributes?.mark??''; + const n=sec.content??''; + const pl=sec.attributes?.per_line; + let html=`${depth?'':''}${esc(m)}${esc(n)}${pl?`${esc(pl)}개/줄`:''}`; + for(const c of sec.children??[]) if(c.type==='SECTION') html+=walkSec(c,depth+1); + return html; } - return `
-
- ${esc(mark||'·')} - ${esc(name||'이름 없음')} -
- ${body?`
${body}
`:''} -
`; + const tm=FT.attributes?.mark??''; + const tt=FT.attributes?.title??''; + let html=tt?`${esc(tm)}${esc(tt)}`:''; + for(const c of FT.children??[]) if(c.type==='SECTION') html+=walkSec(c,0); + el.innerHTML=html; } +// ── 파싱 & 합치기 ────────────────────────────────────── +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)&¤t.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({ + 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) return; + const el=document.getElementById('fillerOut'); + if(!FT||!fillerInstCount){ + el.className='preview-text dim'; + el.textContent='양식을 붙여넣으면 합치기 결과가 표시됩니다'; + return; + } if(fillerTab==='text'){ - const t=fillerToText(); - el.className=t.trim()?'preview-text':'preview-text dim'; - el.textContent=t.trim()||'내용을 채우면 미리보기가 표시됩니다'; + el.className='preview-text'; el.textContent=mergedToText(); } else { - el.className='preview-text'; - el.innerHTML=highlight(fillerToJSON()); + 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); -} - // ════════════════════════════════════════════════════════ // 탭 전환 // ════════════════════════════════════════════════════════