ui: add collect/fixed role distinction to nodes (v0.6)

- Builder: 역할 토글(🔒고정/📥수집) in detail panel struct settings
- Canvas: collect 노드 헤더 파란 배경 + 📥 배지
- Tab2 template view: selective mode일 때 수집/고정 배지 표시
- Tab2 merge: collect 섹션만 토큰 수집, 고정 섹션은 템플릿 값 그대로 출력
- Backwards compatible: collect 섹션이 없으면 기존처럼 전체 수집

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

View File

@@ -237,6 +237,14 @@ input,textarea,button,select{font-family:inherit}
.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{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:focus{border-color:var(--border-focus)}
.filler-textarea::placeholder{color:var(--muted)} .filler-textarea::placeholder{color:var(--muted)}
/* ── 수집/고정 구분 ──────────────────────────── */
.cnode.collect .cnode-head{background:#e8eeff}
.cnode.collect.selected .cnode-head{background:var(--accent-bg)}
.ftpl-sec.is-collect{border-color:var(--accent);background:var(--accent-bg)}
.ftpl-role{font-size:9px;border-radius:3px;padding:1px 5px;font-weight:700;margin-left:2px;letter-spacing:.03em}
.ftpl-role.collect{background:var(--accent-bg);color:var(--accent)}
.ftpl-role.fixed{background:var(--surface-alt);color:var(--muted);border:1px solid var(--border)}
</style> </style>
</head> </head>
<body> <body>
@@ -244,7 +252,7 @@ input,textarea,button,select{font-family:inherit}
<header class="app-header"> <header class="app-header">
<span style="font-size:18px">🏗️</span> <span style="font-size:18px">🏗️</span>
<h1>CTStruct</h1> <h1>CTStruct</h1>
<span class="badge">v0.5</span> <span class="badge">v0.6</span>
</header> </header>
<nav class="main-nav"> <nav class="main-nav">
@@ -716,13 +724,15 @@ function renderCanvas(){
function renderCNode(node){ function renderCNode(node){
const p=pos.get(node.id); if(!p) return; const p=pos.get(node.id); if(!p) return;
const sel=node.id===selectedNodeId; const sel=node.id===selectedNodeId;
const col=node.settings?.attributes?.collect==='true';
const vals=node.values.filter(v=>v.type!=='node').length; const vals=node.values.filter(v=>v.type!=='node').length;
const kids=node.values.filter(v=>v.type==='node').length; const kids=node.values.filter(v=>v.type==='node').length;
const hint=[vals?`${vals}`:'',kids?`하위 ${kids}`:''].filter(Boolean).join(' · ')||'비어있음'; const hint=[vals?`${vals}`:'',kids?`하위 ${kids}`:''].filter(Boolean).join(' · ')||'비어있음';
cards+=`<div class="cnode${sel?' selected':''}" style="left:${p.x}px;top:${p.y}px" onclick="selectNode('${node.id}')"> cards+=`<div class="cnode${sel?' selected':''}${col?' collect':''}" style="left:${p.x}px;top:${p.y}px" onclick="selectNode('${node.id}')">
<div class="cnode-head"> <div class="cnode-head">
<span class="cnode-mark">${esc(node.mark||'·')}</span> <span class="cnode-mark">${esc(node.mark||'·')}</span>
<span class="cnode-name" title="${esc(node.content||'')}">${esc(node.content||'이름 없음')}</span> <span class="cnode-name" title="${esc(node.content||'')}">${esc(node.content||'이름 없음')}</span>
${col?'<span style="font-size:9px;color:var(--accent);font-weight:700;flex-shrink:0;line-height:1">📥</span>':''}
</div> </div>
<div class="cnode-foot"> <div class="cnode-foot">
<span class="cnode-vals">${hint}</span> <span class="cnode-vals">${hint}</span>
@@ -845,8 +855,21 @@ function setNameNL(nid, val){
n.nameNewline=val; refresh(); n.nameNewline=val; refresh();
} }
function setCollect(nid, on){
const n=nodeMap[nid]; if(!n) return;
if(on){
if(!n.settings.attributes) n.settings.attributes={};
n.settings.attributes.collect='true';
} else {
if(n.settings.attributes){ delete n.settings.attributes.collect; }
if(n.settings.attributes && !Object.keys(n.settings.attributes).length) delete n.settings.attributes;
}
refresh();
}
function renderStructSettings(node){ function renderStructSettings(node){
const nid=node.id, nl=node.nameNewline??true; const nid=node.id, nl=node.nameNewline??true;
const col=node.settings?.attributes?.collect==='true';
return ` return `
<div class="struct-sep"> <div class="struct-sep">
<div class="sep-bar-row"> <div class="sep-bar-row">
@@ -854,6 +877,11 @@ function renderStructSettings(node){
<span class="sep-chip${nl?' on':''}" onclick="setNameNL('${nid}',true)">줄바꿈</span> <span class="sep-chip${nl?' on':''}" onclick="setNameNL('${nid}',true)">줄바꿈</span>
<span class="sep-chip${!nl?' on':''}" onclick="setNameNL('${nid}',false)">없음</span> <span class="sep-chip${!nl?' on':''}" onclick="setNameNL('${nid}',false)">없음</span>
</div> </div>
<div class="sep-bar-row">
<span class="sep-bar-lbl">역할</span>
<span class="sep-chip${!col?' on':''}" onclick="setCollect('${nid}',false)" title="값이 항상 고정 출력됩니다">🔒 고정</span>
<span class="sep-chip${col?' on':''}" onclick="setCollect('${nid}',true)" title="합치기 시 값이 수집됩니다">📥 수집</span>
</div>
</div>`; </div>`;
} }
@@ -1175,15 +1203,26 @@ function clearFiller(){
updateFillerPreview(); updateFillerPreview();
} }
// ── 수집 섹션 키 추출 ──────────────────────────────────
function getCollectKeys(root){
const keys=new Set();
function walk(n){ for(const c of n.children??[]){ if(c.type==='SECTION'){ if(c.attributes?.collect==='true'){ keys.add((c.attributes?.mark??'')+(c.content??'')); } walk(c); } } }
walk(root);
return keys;
}
// ── 템플릿 구조 표시 ─────────────────────────────────── // ── 템플릿 구조 표시 ───────────────────────────────────
function renderFillerTemplateView(){ function renderFillerTemplateView(){
const el=document.getElementById('ftplView'); const el=document.getElementById('ftplView');
if(!FT){ el.innerHTML='<span class="ftpl-empty">← 템플릿을 먼저 불러오세요</span>'; return; } if(!FT){ el.innerHTML='<span class="ftpl-empty">← 템플릿을 먼저 불러오세요</span>'; return; }
const selective=getCollectKeys(FT).size>0;
function walkSec(sec, depth){ function walkSec(sec, depth){
const m=sec.attributes?.mark??''; const m=sec.attributes?.mark??'';
const n=sec.content??''; const n=sec.content??'';
const pl=sec.attributes?.per_line; 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>`; const isCol=sec.attributes?.collect==='true';
const roleHtml=selective?`<span class="ftpl-role ${isCol?'collect':'fixed'}">${isCol?'📥수집':'🔒고정'}</span>`:'';
let html=`<span class="ftpl-sec${isCol?' is-collect':''}">${depth?'<span class="ftpl-arr">▸</span>':''}<span class="ftpl-mark">${esc(m)}</span><span class="ftpl-name">${esc(n)}</span>${roleHtml}${pl?`<span class="ftpl-badge">${esc(pl)}개/줄</span>`:''}</span>`;
for(const c of sec.children??[]) if(c.type==='SECTION') html+=walkSec(c,depth+1); for(const c of sec.children??[]) if(c.type==='SECTION') html+=walkSec(c,depth+1);
return html; return html;
} }
@@ -1250,9 +1289,12 @@ function parseAndMerge(){
const instances=splitIntoInstances(rawText,titleMark); const instances=splitIntoInstances(rawText,titleMark);
fillerInstCount=instances.length; fillerInstCount=instances.length;
fillerMerged={}; fillerMerged={};
const collectKeys=getCollectKeys(FT);
const selective=collectKeys.size>0;
for(const text of instances){ for(const text of instances){
const parsed=parseInstance(text,titleMark,sectionMarks); const parsed=parseInstance(text,titleMark,sectionMarks);
for(const [key,tokens] of Object.entries(parsed.sections)){ for(const [key,tokens] of Object.entries(parsed.sections)){
if(selective&&!collectKeys.has(key)) continue;
if(!fillerMerged[key]) fillerMerged[key]=[]; if(!fillerMerged[key]) fillerMerged[key]=[];
fillerMerged[key].push(...tokens); fillerMerged[key].push(...tokens);
} }
@@ -1264,18 +1306,27 @@ function parseAndMerge(){
// ── 텍스트 출력 ──────────────────────────────────────── // ── 텍스트 출력 ────────────────────────────────────────
function mergedToText(){ function mergedToText(){
if(!FT) return ''; if(!FT) return '';
const collectKeys=getCollectKeys(FT);
const selective=collectKeys.size>0;
function walkSec(sec){ function walkSec(sec){
const mark=sec.attributes?.mark??''; const mark=sec.attributes?.mark??'';
const content=sec.content??''; const content=sec.content??'';
const key=mark+content; const key=mark+content;
const isCol=selective?(sec.attributes?.collect==='true'):true;
const perLine=parseInt(sec.attributes?.per_line??'0')||0; const perLine=parseInt(sec.attributes?.per_line??'0')||0;
const tokens=fillerMerged[key]??[];
let t=mark+content+'\n'; let t=mark+content+'\n';
if(tokens.length){ if(isCol){
if(perLine>0){ const tokens=fillerMerged[key]??[];
for(let i=0;i<tokens.length;i+=perLine) t+=tokens.slice(i,i+perLine).join(' ')+'\n'; if(tokens.length){
} else { if(perLine>0){
t+=tokens.join(' ')+'\n'; for(let i=0;i<tokens.length;i+=perLine) t+=tokens.slice(i,i+perLine).join(' ')+'\n';
} else {
t+=tokens.join(' ')+'\n';
}
}
} else {
for(const c of sec.children??[]){
if(c.type==='VALUE'&&c.value!=null&&String(c.value).trim()) t+=String(c.value)+'\n';
} }
} }
for(const c of sec.children??[]) if(c.type==='SECTION') t+=walkSec(c); for(const c of sec.children??[]) if(c.type==='SECTION') t+=walkSec(c);
@@ -1291,14 +1342,20 @@ function mergedToText(){
// ── JSON 출력 ─────────────────────────────────────────── // ── JSON 출력 ───────────────────────────────────────────
function mergedToJSON(){ function mergedToJSON(){
if(!FT) return '{}'; if(!FT) return '{}';
const collectKeys=getCollectKeys(FT);
const selective=collectKeys.size>0;
function buildSec(sec){ function buildSec(sec){
const mark=sec.attributes?.mark??''; const mark=sec.attributes?.mark??'';
const content=sec.content??''; const content=sec.content??'';
const key=mark+content; const key=mark+content;
const tokens=fillerMerged[key]??[]; const isCol=selective?(sec.attributes?.collect==='true'):true;
const valChildren=tokens.map((t,i)=>({ let valChildren;
id:'mv'+i,type:'VALUE',data_type:'string',value:t if(isCol){
})); const tokens=fillerMerged[key]??[];
valChildren=tokens.map((t,i)=>({id:'mv'+i,type:'VALUE',data_type:'string',value:t}));
} else {
valChildren=(sec.children??[]).filter(c=>c.type==='VALUE');
}
const secChildren=(sec.children??[]).filter(c=>c.type==='SECTION').map(buildSec); const secChildren=(sec.children??[]).filter(c=>c.type==='SECTION').map(buildSec);
return {...sec, children:[...valChildren,...secChildren]}; return {...sec, children:[...valChildren,...secChildren]};
} }