From 3449df10bc760d84cfddadb00651bdc26238b239 Mon Sep 17 00:00:00 2001 From: gm Date: Wed, 3 Jun 2026 01:53:50 +0900 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20=EA=B5=AC=EC=A1=B0=20=EB=8C=80?= =?UTF-8?q?=ED=8F=AD=20=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자료형: node/string/int/bool/date 5개만 유지, integer→int, boolean→bool - 리스트 타입 완전 제거 (기존 데이터 → string으로 자동 마이그레이션) - nextSep/childSep 제거 (노드 간 구분은 항상 줄바꿈) - header/footer 제거 - prefix = 노드 전체 앞 문자열, suffix = 노드 전체 뒤 문자열로 재정의 - toText(): prefix/suffix 렌더링, list 로직 제거, 구조 단순화 - toJSON(): list/nextSep/childSep/header/footer 제거 - parseJsonSection: 동일하게 단순화, 구버전 LIST → string 호환 - doImport: flush() → string value 생성 - CSS: list 전용 스타일 제거 Co-Authored-By: Claude Sonnet 4.6 --- ui/builder.html | 283 +++++++++--------------------------------------- 1 file changed, 54 insertions(+), 229 deletions(-) diff --git a/ui/builder.html b/ui/builder.html index abd40a2..2ac5e6a 100644 --- a/ui/builder.html +++ b/ui/builder.html @@ -81,29 +81,12 @@ input,textarea,button,select{font-family:inherit} .val-item.val-node{border:none;background:transparent;padding:0} .val-head{display:flex;align-items:center;gap:5px;padding:4px 6px;background:var(--surface);border-bottom:1px solid var(--border)} .val-type-badge{font-size:10px;font-weight:700;color:var(--muted);background:var(--surface-alt);border:1px solid var(--border);border-radius:3px;padding:1px 5px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0} -.val-cat-inp{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 6px;background:var(--surface);outline:none;min-width:0} -.val-cat-inp:focus{border-color:var(--border-focus)} -.val-cat-inp::placeholder{color:var(--muted)} .val-body{padding:6px} -.val-names{width:100%;min-height:42px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:4px 6px;resize:vertical;background:var(--surface);line-height:1.55;outline:none} -.val-names:focus{border-color:var(--border-focus)} .val-data-inp{width:100%;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:4px 6px;background:var(--surface);outline:none} .val-data-inp:focus{border-color:var(--border-focus)} -.val-data-ta{width:100%;min-height:60px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:4px 6px;resize:vertical;background:var(--surface);font-family:monospace;line-height:1.5;outline:none} -.val-data-ta:focus{border-color:var(--border-focus)} -.val-opts{display:flex;flex-direction:column;gap:4px;margin-top:5px;padding-top:5px;border-top:1px dashed var(--border)} -.opt-row{display:flex;align-items:center;gap:5px} -.opt-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);width:36px;flex-shrink:0} -.sep-chips{display:flex;gap:3px;flex-wrap:wrap} -.sep-chip{font-size:11px;padding:2px 6px;border-radius:4px;border:1px solid var(--border);background:var(--surface);color:var(--muted);cursor:pointer;font-weight:500;transition:all .1s;white-space:nowrap} +.sep-chip{font-size:11px;padding:2px 8px;border-radius:4px;border:1px solid var(--border);background:var(--surface);color:var(--muted);cursor:pointer;font-weight:500;transition:all .1s;white-space:nowrap} .sep-chip:hover{border-color:var(--accent);color:var(--accent)} .sep-chip.on{background:var(--accent);color:#fff;border-color:var(--accent)} -.sep-cust{width:48px;font-size:11px;border:1px solid var(--border);border-radius:4px;padding:2px 4px;background:var(--surface);text-align:center;outline:none} -.sep-cust:focus{border-color:var(--border-focus)} -.npl-inp{width:44px;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 4px;text-align:center;background:var(--surface);outline:none} -.npl-inp:focus{border-color:var(--border-focus)} -.npl-inp:disabled{background:var(--surface-alt);color:var(--muted)} -.opt-hint{font-size:10px;color:var(--muted)} /* ── 구분자/이름줄바꿈 설정 (노드 설정 내부) ─ */ .struct-sep{display:flex;flex-direction:column;gap:4px;padding-bottom:6px;margin-bottom:6px;border-bottom:1px dashed var(--border)} @@ -252,27 +235,16 @@ input,textarea,button,select{font-family:inherit} // 상수 // ════════════════════════════════════════════════════════ const VALUE_TYPES = [ - {v:'node', lbl:'노드 (Node)'}, - {v:'string', lbl:'문자열 (String)'}, - {v:'integer', lbl:'정수 (Integer)'}, - {v:'float', lbl:'실수 (Float)'}, - {v:'boolean', lbl:'불리언 (Boolean)'}, - {v:'null', lbl:'Null'}, - {v:'date', lbl:'날짜 (Date)'}, - {v:'datetime', lbl:'날짜+시간 (Datetime)'}, - {v:'time', lbl:'시간 (Time)'}, - {v:'markdown', lbl:'Markdown'}, - {v:'html', lbl:'HTML'}, - {v:'json', lbl:'JSON'}, - {v:'code', lbl:'코드 (Code)'}, - {v:'custom', lbl:'사용자 정의 (Custom)'}, + {v:'node', lbl:'노드 (Node)'}, + {v:'string', lbl:'문자열 (String)'}, + {v:'int', lbl:'정수 (Int)'}, + {v:'bool', lbl:'불리언 (Bool)'}, + {v:'date', lbl:'날짜 (Date)'}, ]; const SETTING_OPTIONS = [ - {v:'header', lbl:'머리글 (header)'}, - {v:'footer', lbl:'꼬리글 (footer)'}, - {v:'prefix', lbl:'접두사 (prefix)'}, - {v:'suffix', lbl:'접미사 (suffix)'}, + {v:'prefix', lbl:'앞 문자열 (prefix)'}, + {v:'suffix', lbl:'뒤 문자열 (suffix)'}, {v:'attributes', lbl:'어트리뷰트'}, ]; @@ -291,13 +263,6 @@ const ATTR_KEYS = [ {v:'_custom', lbl:'직접 입력...'}, ]; -const SEP_PRESETS = [ - {v:' ', lbl:'공백'}, - {v:', ', lbl:', '}, - {v:' / ', lbl:'/'}, - {v:'\n', lbl:'↵'}, -]; - // ════════════════════════════════════════════════════════ // 팩토리 // ════════════════════════════════════════════════════════ @@ -305,10 +270,7 @@ function uid(){ return Math.random().toString(36).slice(2,9) } function mkNode(mark='', content=''){ return { id:uid(), mark, content, hidden:false, values:[], settings:{}, - nextSep:'blank', childSep:'blank', nameNewline:true }; -} -function mkListVal(names='', category=''){ - return { id:uid(), type:'list', names, sep:' ', namesPerLine:0, category }; + nameNewline:true }; } function mkNodeVal(childNode){ return { id:uid(), type:'node', node: childNode ?? mkNode() }; @@ -353,11 +315,20 @@ function load(){ S=buildExample(); } +const VALID_TYPES=new Set(['node','string','int','bool','date']); function normalizeNode(n){ - if(n.nextSep===undefined) n.nextSep='blank'; - if(n.childSep===undefined) n.childSep='blank'; if(n.nameNewline===undefined) n.nameNewline=true; - for(const v of n.values||[]) if(v.type==='node') normalizeNode(v.node); + // list → string, 지원하지 않는 타입 → string + n.values=(n.values||[]).map(v=>{ + if(v.type==='list'){ + const sv=mkScalarVal('string'); sv.data=(v.names??'').trim(); return sv; + } + if(v.type==='node'){ normalizeNode(v.node); return v; } + if(v.type==='integer') v.type='int'; + if(v.type==='boolean') v.type='bool'; + if(!VALID_TYPES.has(v.type)) v.type='string'; + return v; + }); } function normalizeState(s){ for(const n of s.nodes||[]) normalizeNode(n); @@ -376,35 +347,28 @@ function migrateOld(o){ function migSec(s, oldMarks){ const mark = s.mark ?? oldMarks[(s.lv??1)-1] ?? ''; const n = mkNode(mark, s.content ?? s.name ?? ''); - n.hidden = false; - // groups → list values + // groups → string values (names 합치기) for(const g of s.groups??[]){ - const lv = mkListVal(g.names??'', g.attributes?.category??''); - lv.sep = g.sep??' '; lv.namesPerLine = g.namesPerLine??0; - n.values.push(lv); + const names=(g.names??'').trim(); if(!names) continue; + const sv=mkScalarVal('string'); sv.data=names; n.values.push(sv); } - // children → node values for(const c of s.children??[]) n.values.push(mkNodeVal(migSec(c, oldMarks))); - // scalar value if(s.value!=null && s.data_type && s.data_type!=='unset'){ - const sv=mkScalarVal(s.data_type); sv.data=String(s.value??''); n.values.push(sv); + const sv=mkScalarVal('string'); sv.data=String(s.value??''); n.values.push(sv); } - // settings - if(s.header) n.settings.header=s.header; - if(s.footer) n.settings.footer=s.footer; if(s.prefix) n.settings.prefix=s.prefix; if(s.suffix) n.settings.suffix=s.suffix; - const filtAttrs=Object.entries(s.attributes??{}).filter(([k])=>k!=='level'&&k!=='category'); + const filtAttrs=Object.entries(s.attributes??{}).filter(([k])=>k!=='level'&&k!=='category'&&k!=='mark'); if(filtAttrs.length) n.settings.attributes=Object.fromEntries(filtAttrs); return n; } function buildExample(){ const n1=mkNode('🔹','1번 항목'); - n1.values.push(mkListVal('홍길동 홍길순 홍길영 홍길준\n홍길호 홍길윤','')); + const sv1=mkScalarVal('string'); sv1.data='홍길동 홍길순 홍길영 홍길준'; n1.values.push(sv1); const n2=mkNode('🔹','2번 항목'); - const c1=mkNode('🔸','2-1 항목'); c1.values.push(mkListVal('홍길찬 홍길만','건강')); - const c2=mkNode('🔸','2-2 항목'); c2.values.push(mkListVal('홍길주','직장')); + const c1=mkNode('🔸','2-1 항목'); const sv2=mkScalarVal('string'); sv2.data='홍길찬 홍길만'; c1.values.push(sv2); + const c2=mkNode('🔸','2-2 항목'); const sv3=mkScalarVal('string'); sv3.data='홍길주'; c2.values.push(sv3); n2.values.push(mkNodeVal(c1)); n2.values.push(mkNodeVal(c2)); return {titleMark:'✅', title:'예시 양식', nodes:[n1,n2]}; } @@ -440,12 +404,8 @@ function toggleHidden(nid){ function addValue(nid, type){ if(!type) return; const n=nodeMap[nid]; if(!n) return; - let v; - if(type==='list') v=mkListVal(); - else if(type==='node') v=mkNodeVal(); - else v=mkScalarVal(type); + const v = type==='node' ? mkNodeVal() : mkScalarVal(type); n.values.push(v); refresh(); - if(type==='list') focusNew('.val-names'); } function removeValue(vid){ const e=valueMap[vid]; if(!e) return; @@ -590,47 +550,7 @@ function renderValue(nid, val){ return `
${renderNode(val.node,true)}
`; } - if(val.type==='list'){ - const sep=val.sep??' ', npl=val.namesPerLine??0, isNl=sep==='\n'; - const chips=SEP_PRESETS.map(p=>` - ${p.lbl}`).join(''); - const customVal=SEP_PRESETS.find(p=>p.v===sep)?'':esc(sep); - return ` -
-
- LIST - - ${mvDel} -
-
- -
-
- 구분 -
${chips}
- -
-
- 줄당 - - ${isNl?'(줄바꿈 모드)':'개 (0=무제한)'} -
-
-
-
`; - } - - // 스칼라 타입 const typeLbl=VALUE_TYPES.find(t=>t.v===val.type)?.lbl??val.type.toUpperCase(); - const isMulti=['markdown','html','json','code'].includes(val.type); return `
@@ -639,60 +559,22 @@ function renderValue(nid, val){ ${mvDel}
- ${isMulti - ?`` - :`` - } +
`; } -const SEP_CFG = [ - {v:'none', lbl:'없음'}, - {v:'newline', lbl:'줄바꿈'}, - {v:'blank', lbl:'빈 줄'}, -]; - -function setSep(nid, field, val){ - const n=nodeMap[nid]; if(!n) return; - n[field]=val; refresh(); -} - -function renderSepChips(nid, field, current){ - const isCustom = !SEP_CFG.find(p=>p.v===current); - const chips = SEP_CFG.map(p=>` - ${p.lbl}`).join(''); - return ` - ${chips} - `; -} - function setNameNL(nid, val){ const n=nodeMap[nid]; if(!n) return; n.nameNewline=val; refresh(); } function renderStructSettings(node){ - const nid=node.id; - const nl=node.nameNewline??true; + const nid=node.id, nl=node.nameNewline??true; return `
-
- 다음 노드 - ${renderSepChips(nid,'nextSep', node.nextSep??'blank')} -
-
- 하위 노드 - ${renderSepChips(nid,'childSep', node.childSep??'blank')} -
이름 뒤 줄바꿈 @@ -746,65 +628,25 @@ function renderSetting(nid, key, val){ function parseNames(raw){ return (raw??'').trim().split(/[\s,\n]+/).filter(Boolean) } function toText(){ - // 구분자 값 → 실제 문자열 - function ss(sep){ - const s=sep??'blank'; - if(s==='none') return ''; - if(s==='newline') return '\n'; - if(s==='blank') return '\n\n'; - return String(s); // 직접 입력값 (예: '/', ', ') - } - - // 노드 하나를 문자열로 렌더링 function rNode(node){ - let t=`${node.mark??''}${node.content||''}`; - const nl=(node.nameNewline??true)?'\n':''; // 이름 뒤 줄바꿈 여부 - let pendingSep=null; - let prevWasList=false; - let firstContent=true; // 첫 번째 list/scalar 여부 (nameNewline 적용 대상) - - for(let i=0;i(j===0?pre:'')+n).join('\n'); - else if(npl>0) lt=Array.from({length:Math.ceil(ns.length/npl)},(_,j)=>(j===0?pre:'')+ns.slice(j*npl,(j+1)*npl).join(lsep)).join('\n'); - else lt=pre+ns.join(lsep); - const pre2=firstContent?nl:'\n'; // 첫 list만 nameNewline 적용 - t+=pre2+lt+'\n'; - prevWasList=true; pendingSep=null; firstContent=false; - - } else if(v.type==='node'){ - if(!prevWasList){ - t+=(pendingSep!==null) ? pendingSep : ss(node.childSep??'blank'); - } - t+=rNode(v.node); - prevWasList=false; firstContent=false; - pendingSep=(nextV&&nextV.type==='node') ? ss(v.node.nextSep??'blank') : null; - - } else { - if(v.data){ - const pre2=firstContent?nl:'\n'; - t+=pre2+String(v.data); - firstContent=false; - } - prevWasList=false; pendingSep=null; + const pre=node.settings.prefix??''; + const suf=node.settings.suffix??''; + const nl=(node.nameNewline??true)?'\n':''; + let t=pre+`${node.mark??''}${node.content||''}`; + let first=true; + for(const v of node.values){ + if(v.type==='node'){ + t+='\n'+rNode(v.node); first=false; + } else if(v.data!==undefined&&v.data!==null&&String(v.data).length){ + t+=(first?nl:'\n')+String(v.data); first=false; } } + t+=suf; return t; } - const title=S.title.trim(); - let out=title?`${S.titleMark}${title}\n\n`:''; - for(let i=0;i{ - if(v.type==='list'){ - const ns=parseNames(v.names); - return buildNode({id:v.id,type:'LIST', - attributes:v.category?{category:v.category}:{}, - children:ns.map(n=>buildNode({id:uid(),type:'LIST_ITEM',content:n}))}); - } if(v.type==='node') return serNode(v.node); return buildNode({id:v.id,type:'VALUE',data_type:v.type,value:v.data??null}); }); const attrs={mark:node.mark,level:depth,...(node.settings.attributes??{})}; const r=buildNode({ id:node.id,type:'SECTION',content:node.content, - header:node.settings.header??null, - footer:node.settings.footer??null, prefix:node.settings.prefix??null, suffix:node.settings.suffix??null, attributes:attrs,children, - nextSep:node.nextSep??'blank', - childSep:node.childSep??'blank', nameNewline:node.nameNewline??true, }); depth--; @@ -959,29 +791,22 @@ function doImportJson(){ function parseJsonSection(sec){ const mark=sec.attributes?.mark??''; const n=mkNode(mark, sec.content??''); - - if(sec.nextSep!==undefined) n.nextSep=sec.nextSep; - if(sec.childSep!==undefined) n.childSep=sec.childSep; if(sec.nameNewline!==undefined) n.nameNewline=sec.nameNewline; - - if(sec.header) n.settings.header=sec.header; - if(sec.footer) n.settings.footer=sec.footer; if(sec.prefix) n.settings.prefix=sec.prefix; if(sec.suffix) n.settings.suffix=sec.suffix; - const filtAttrs=Object.entries(sec.attributes??{}).filter(([k])=>k!=='mark'&&k!=='level'); if(filtAttrs.length) n.settings.attributes=Object.fromEntries(filtAttrs); - for(const child of sec.children??[]){ - if(child.type==='LIST'){ - const names=(child.children??[]).filter(c=>c.type==='LIST_ITEM').map(c=>c.content??'').join(' '); - n.values.push(mkListVal(names, child.attributes?.category??'')); - } else if(child.type==='SECTION'){ + if(child.type==='SECTION'){ n.values.push(mkNodeVal(parseJsonSection(child))); } else if(child.type==='VALUE'){ const sv=mkScalarVal(child.data_type??'string'); sv.data=String(child.value??''); n.values.push(sv); + } else if(child.type==='LIST'){ + // 구버전 호환: LIST → string value + const names=(child.children??[]).filter(c=>c.type==='LIST_ITEM').map(c=>c.content??'').join(' '); + const sv=mkScalarVal('string'); sv.data=names; n.values.push(sv); } } return n; @@ -998,8 +823,8 @@ function doImport(){ function flush(){ if(!pend||!pend.names.length){ pend=null; return; } const parent=pend.parentId?findNodeInList(nodes,pend.parentId):null; - const lv=mkListVal(pend.names.join(' '),pend.cat||''); - if(parent) parent.values.push(lv); pend=null; + const sv=mkScalarVal('string'); sv.data=pend.names.join(' '); + if(parent) parent.values.push(sv); pend=null; } function findNodeInList(list,id){ for(const n of list){ if(n.id===id) return n; for(const v of n.values) if(v.type==='node'){ const f=findNodeInList([v.node],id); if(f) return f; } } return null; } function topId(){ return stack.length?stack[stack.length-1].id:null }