feat(ui): 노드 구분자 설정 추가 (다음 노드 / 하위 노드)

각 노드 카드 바디 상단에 구분자 설정 바 추가.
- 다음 노드 (nextSep): 이 노드 뒤~다음 형제 노드 사이 구분
- 하위 노드 (childSep): 이 노드 헤더~첫 자식 노드 사이 구분
- 프리셋: 없음 / 빈 줄 / 직접 입력
- toText()에 applySep() 적용, list 뒤 빈 줄은 기존 유지
- 기존 저장 데이터 normalizeState()로 자동 마이그레이션

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gm
2026-06-03 00:07:31 +09:00
parent 65f5d4b422
commit 881b3d1f10

View File

@@ -105,6 +105,11 @@ input,textarea,button,select{font-family:inherit}
.npl-inp:disabled{background:var(--surface-alt);color:var(--muted)}
.opt-hint{font-size:10px;color:var(--muted)}
/* ── 구분자 설정 바 ───────────────────────── */
.sep-bar{background:var(--surface-alt);border:1px solid var(--border);border-radius:5px;padding:5px 8px;display:flex;flex-direction:column;gap:4px}
.sep-bar-row{display:flex;align-items:center;gap:5px}
.sep-bar-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);width:52px;flex-shrink:0}
/* ── 노드 설정 ────────────────────────────── */
.setting-row{display:flex;align-items:center;gap:5px;padding:3px 0}
.setting-lbl{font-size:11px;color:var(--muted);width:54px;flex-shrink:0}
@@ -278,7 +283,8 @@ const SEP_PRESETS = [
function uid(){ return Math.random().toString(36).slice(2,9) }
function mkNode(mark='', content=''){
return { id:uid(), mark, content, hidden:false, values:[], settings:{} };
return { id:uid(), mark, content, hidden:false, values:[], settings:{},
nextSep:'blank', childSep:'blank' };
}
function mkListVal(names='', category=''){
return { id:uid(), type:'list', names, sep:' ', namesPerLine:0, category };
@@ -319,15 +325,22 @@ function save(){ try{ localStorage.setItem('ctstruct-v4', JSON.stringify(S)) }ca
function load(){
try{
const raw=localStorage.getItem('ctstruct-v4');
if(raw){ S=JSON.parse(raw); return; }
// try migrating old format
if(raw){ S=JSON.parse(raw); normalizeState(S); return; }
const old=localStorage.getItem('ctstruct-builder');
if(old){ const o=JSON.parse(old); S=migrateOld(o); save(); return; }
}catch(_){}
// default example
S=buildExample();
}
function normalizeNode(n){
if(n.nextSep===undefined) n.nextSep='blank';
if(n.childSep===undefined) n.childSep='blank';
for(const v of n.values||[]) if(v.type==='node') normalizeNode(v.node);
}
function normalizeState(s){
for(const n of s.nodes||[]) normalizeNode(n);
}
function migrateOld(o){
const titleMark = o.titleMark ?? o.cfg?.titleMark ?? '✅';
const oldMarks = o.cfg?.marks ?? [];
@@ -511,6 +524,7 @@ function renderNode(node, nested){
</div>
${!node.hidden?`
<div class="node-body">
${renderSepBar(node)}
<!-- 값 -->
<div class="body-sec">
<div class="body-sec-hdr">
@@ -616,6 +630,43 @@ function renderValue(nid, val){
</div>`;
}
const SEP_CFG = [
{v:'none', 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=>`
<span class="sep-chip${current===p.v?' on':''}"
onclick="setSep('${nid}','${field}','${p.v}')">${p.lbl}</span>`).join('');
return `
${chips}
<input class="sep-cust" placeholder="직접" title="직접 입력"
value="${isCustom?esc(current):''}"
oninput="setSep('${nid}','${field}',this.value||'none')"
style="width:56px">`;
}
function renderSepBar(node){
return `
<div class="sep-bar">
<div class="sep-bar-row">
<span class="sep-bar-lbl">다음 노드</span>
${renderSepChips(node.id,'nextSep', node.nextSep??'blank')}
</div>
<div class="sep-bar-row">
<span class="sep-bar-lbl">하위 노드</span>
${renderSepChips(node.id,'childSep', node.childSep??'blank')}
</div>
</div>`;
}
function renderSetting(nid, key, val){
if(key==='attributes'){
const attrs=val??{};
@@ -665,27 +716,44 @@ function toText(){
const title=S.title.trim();
if(title){ lines.push(`${S.titleMark}${title}`); lines.push(''); }
function applySep(sep){
const s=sep??'blank';
if(s==='none') return;
if(s==='blank'){ lines.push(''); return; }
for(const l of String(s).replace(/\\n/g,'\n').split('\n')) lines.push(l);
}
function rNode(node){
lines.push(`${node.mark??''}${node.content||''}`);
let hadGroup=false;
for(const v of node.values){
let prevWasList=false;
for(let i=0;i<node.values.length;i++){
const v=node.values[i];
const nextV=node.values[i+1]??null;
if(v.type==='list'){
const ns=parseNames(v.names); if(!ns.length) continue;
const cat=(v.category??'').trim(), pre=cat?`(${cat}) `:'';
const sep=v.sep??' ', npl=v.namesPerLine??0;
if(sep==='\n') ns.forEach((n,i)=>lines.push((i===0?pre:'')+n));
else if(npl>0) for(let i=0;i<ns.length;i+=npl) lines.push((i===0?pre:'')+ns.slice(i,i+npl).join(sep));
if(sep==='\n') ns.forEach((n,j)=>lines.push((j===0?pre:'')+n));
else if(npl>0) for(let j=0;j<ns.length;j+=npl) lines.push((j===0?pre:'')+ns.slice(j,j+npl).join(sep));
else lines.push(pre+ns.join(sep));
lines.push(''); hadGroup=true;
lines.push(''); // list 뒤 빈 줄 (항상)
prevWasList=true;
} else if(v.type==='node'){
if(!hadGroup) lines.push('');
rNode(v.node); hadGroup=false;
if(!prevWasList) applySep(node.childSep); // list가 없을 때만 childSep 적용
rNode(v.node);
if(nextV&&nextV.type==='node') applySep(v.node.nextSep); // 다음도 노드면 nextSep
prevWasList=false;
} else {
if(v.data) lines.push(String(v.data));
prevWasList=false;
}
}
}
for(const n of S.nodes) rNode(n);
for(let i=0;i<S.nodes.length;i++){
rNode(S.nodes[i]);
if(i<S.nodes.length-1) applySep(S.nodes[i].nextSep);
}
return lines.join('\n').replace(/\n{3,}/g,'\n\n').trimEnd()+'\n';
}