ui: 2D canvas tree builder (Step 2)

- Replace list-based editor with 2D canvas tree visualization
- Nodes laid out by depth column + subtree-height algorithm
- Bezier curves connect parent→child (accent-colored when selected)
- Click node to select; detail panel on right shows editable fields
- Canvas toolbar holds doc title/mark + add/import/new actions
- Inline renderNode() removed; renderValue() for node-type shows reference widget with → navigate button
- addTopNode()/addChildNode() auto-select the new node
- Preview panel stays in right-panel stacked under detail

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 12:31:36 +09:00
parent 45677d7b18
commit 97e2406044

View File

@@ -159,6 +159,41 @@ input,textarea,button,select{font-family:inherit}
.mBtn.cancel{background:var(--surface);color:var(--muted);border-color:var(--border)}
.mBtn.cancel:hover{background:var(--surface-alt)}
.hsep{height:1px;background:var(--border);margin:10px 0}
/* ── Step 2: 캔버스 빌더 레이아웃 ─────────── */
#panel-builder{flex-direction:column}
.canvas-toolbar{background:var(--surface);border-bottom:1px solid var(--border);padding:8px 12px;display:flex;align-items:center;gap:8px;flex-shrink:0}
.canvas-toolbar .root-row{flex:1;margin:0;border:none;background:transparent;padding:0;box-shadow:none}
.canvas-toolbar .root-row:focus-within{border:none;background:transparent}
.builder-main{flex:1;display:flex;overflow:hidden}
.canvas-wrap{flex:1;overflow:auto;background:#eaecf2;position:relative}
.canvas-inner{position:relative}
.canvas-svg{position:absolute;top:0;left:0;pointer-events:none;overflow:visible}
/* 캔버스 노드 카드 */
.cnode{position:absolute;width:164px;background:var(--surface);border:1.5px solid var(--border);border-radius:var(--r);box-shadow:var(--sh);cursor:pointer;transition:border-color .15s,box-shadow .15s;user-select:none}
.cnode:hover{border-color:var(--border-focus)}
.cnode.selected{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,110,247,.12)}
.cnode-head{display:flex;align-items:center;gap:5px;padding:7px 9px;background:var(--surface-alt);border-bottom:1px solid var(--border);border-radius:calc(var(--r) - 1.5px) calc(var(--r) - 1.5px) 0 0}
.cnode.selected .cnode-head{background:var(--accent-bg)}
.cnode-mark{font-size:14px;flex-shrink:0;line-height:1}
.cnode-name{font-size:12px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cnode-foot{display:flex;align-items:center;padding:4px 8px;gap:4px;min-height:28px}
.cnode-vals{font-size:10px;color:var(--muted);flex:1}
.cnode-btns{display:flex;gap:2px;opacity:0;transition:opacity .1s}
.cnode:hover .cnode-btns,.cnode.selected .cnode-btns{opacity:1}
.cnode-btn{width:18px;height:18px;display:flex;align-items:center;justify-content:center;border-radius:3px;border:none;cursor:pointer;font-size:10px;background:transparent;color:var(--muted);transition:all .1s;padding:0;flex-shrink:0}
.cnode-btn:hover{background:var(--accent-bg);color:var(--accent)}
.cnode-btn.del:hover{background:var(--red-bg);color:var(--red)}
.canvas-empty{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:var(--muted);pointer-events:none;line-height:1.8}
/* 우측 패널 */
.right-panel{width:400px;min-width:260px;display:flex;flex-direction:column;overflow:hidden;border-left:1px solid var(--border)}
.detail-panel{flex:1;overflow-y:auto;background:var(--surface);display:flex;flex-direction:column}
.detail-panel::-webkit-scrollbar{width:5px}
.detail-panel::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.detail-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px;font-style:italic;text-align:center;padding:20px}
.detail-hdr-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);padding:7px 12px 6px;border-bottom:1px solid var(--border);background:var(--surface-alt);flex-shrink:0}
.detail-node-head{display:flex;align-items:center;gap:5px;padding:8px 10px;border-bottom:1px solid var(--border);background:var(--surface-alt);flex-shrink:0}
.detail-body{padding:10px;display:flex;flex-direction:column;gap:8px}
</style>
</head>
<body>
@@ -175,14 +210,9 @@ input,textarea,button,select{font-family:inherit}
</nav>
<div id="panel-builder" class="tab-panel">
<div class="app-body">
<div class="builder-panel">
<div class="panel-lbl">
<span class="panel-lbl-title">✏️ 편집기</span>
<button class="hdr-btn" onclick="openImport()">📥 불러오기</button>
<button class="hdr-btn" onclick="newDoc()">🗒 새 문서</button>
</div>
<div class="builder-scroll">
<div class="canvas-toolbar">
<span style="font-size:16px">🌳</span>
<div class="root-row">
<span class="root-lbl">문서</span>
<input class="mark-inp" id="rootMark" title="제목 구분자"
@@ -190,11 +220,23 @@ input,textarea,button,select{font-family:inherit}
<input class="root-title" id="docTitle" placeholder="제목 없음"
oninput="S.title=this.value;updatePreview();save()">
</div>
<button class="add-node-btn" onclick="addTopNode()"> 노드 추가</button>
<div class="node-list" id="nodeList"></div>
<button class="hdr-btn" onclick="addTopNode()"> 노드 추가</button>
<button class="hdr-btn" onclick="openImport()">📥 불러오기</button>
<button class="hdr-btn" onclick="newDoc()">🗒 새 문서</button>
</div>
<div class="builder-main">
<div class="canvas-wrap" id="canvasWrap">
<div class="canvas-inner" id="canvasInner">
<svg id="canvasSvg" class="canvas-svg"></svg>
<div id="canvasNodes"></div>
</div>
</div>
<div class="right-panel">
<div class="detail-panel" id="detailPanel">
<div class="detail-empty">← 노드를 클릭하면<br>상세 편집이 여기에 표시됩니다</div>
</div>
<div class="preview-panel">
<div class="preview-head">
<div class="tabs">
@@ -209,7 +251,9 @@ input,textarea,button,select{font-family:inherit}
<pre class="preview-text" id="previewOut"></pre>
</div>
</div>
</div>
</div>
</div><!-- /panel-builder -->
<div id="panel-filler" class="tab-panel hidden">
@@ -404,7 +448,10 @@ function buildExample(){
// CRUD — 노드
// ════════════════════════════════════════════════════════
function addTopNode(){
S.nodes.push(mkNode()); refresh(); focusNew('.name-inp');
const node=mkNode();
S.nodes.push(node);
selectedNodeId=node.id;
refresh();
}
function deleteNode(nid){
const vid=valNodeMap[nid];
@@ -432,7 +479,9 @@ function addValue(nid, type){
if(!type) return;
const n=nodeMap[nid]; if(!n) return;
const v = type==='node' ? mkNodeVal() : mkScalarVal(type);
n.values.push(v); refresh();
n.values.push(v);
if(type==='node') selectedNodeId=v.node.id;
refresh();
}
function removeValue(vid){
const e=valueMap[vid]; if(!e) return;
@@ -497,76 +546,168 @@ function removeAttr(nid, key){
// ════════════════════════════════════════════════════════
// 헬퍼
// ════════════════════════════════════════════════════════
function refresh(){ rebuildMaps(); renderPanel(); updatePreview(); save(); }
function focusNew(sel){ setTimeout(()=>{ const els=document.querySelectorAll(sel); if(els.length) els[els.length-1].focus(); },40); }
function refresh(){ rebuildMaps(); renderCanvas(); renderDetail(); updatePreview(); save(); }
function esc(s){ return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') }
// 입력값의 \n \t → 실제 문자 / 실제 문자 → 표시용 이스케이프
function unesc(s){ return s.replace(/\\n/g,'\n').replace(/\\t/g,'\t').replace(/\\r/g,'\r') }
function reesc(s){ return s.replace(/\r/g,'\\r').replace(/\n/g,'\\n').replace(/\t/g,'\\t') }
// ════════════════════════════════════════════════════════
// 렌더링
// 캔버스 레이아웃
// ════════════════════════════════════════════════════════
function renderPanel(){
let selectedNodeId = null;
const C_W=164, C_H=54, C_COL=252, C_ROW=72, C_PAD=28;
function subtreeH(node){
const kids=node.values.filter(v=>v.type==='node').map(v=>v.node);
if(!kids.length) return 1;
return kids.reduce((s,c)=>s+subtreeH(c),0);
}
function computeLayout(){
const pos=new Map();
function place(node,col,ySlot){
const kids=node.values.filter(v=>v.type==='node').map(v=>v.node);
const h=subtreeH(node);
pos.set(node.id,{x:C_PAD+col*C_COL, y:C_PAD+C_ROW*(ySlot+h/2)-C_H/2});
let y=ySlot;
for(const kid of kids){ place(kid,col+1,y); y+=subtreeH(kid); }
}
let y=0;
for(const node of S.nodes){ place(node,0,y); y+=subtreeH(node); }
return pos;
}
function renderCanvas(){
rebuildMaps();
document.getElementById('rootMark').value=S.titleMark;
document.getElementById('docTitle').value=S.title;
document.getElementById('nodeList').innerHTML=S.nodes.map(n=>renderNode(n,false)).join('');
if(S.nodes.length===0){
document.getElementById('canvasNodes').innerHTML=
`<div class="canvas-empty"><div style="font-size:38px">🌱</div><div style="margin-top:8px;font-size:13px;font-weight:500">노드가 없습니다</div><div style="font-size:11px;margin-top:4px;color:var(--muted)">위의 노드 추가 버튼으로 시작하세요</div></div>`;
document.getElementById('canvasSvg').innerHTML='';
return;
}
const pos=computeLayout();
const wrap=document.getElementById('canvasWrap');
const inner=document.getElementById('canvasInner');
let maxX=wrap.clientWidth||600, maxY=wrap.clientHeight||400;
pos.forEach(({x,y})=>{ maxX=Math.max(maxX,x+C_W+C_PAD); maxY=Math.max(maxY,y+C_H+C_PAD); });
inner.style.width=maxX+'px'; inner.style.height=maxY+'px';
// SVG 연결선
let paths='';
function drawLines(node){
const p=pos.get(node.id); if(!p) return;
const px=p.x+C_W, py=p.y+C_H/2;
const kids=node.values.filter(v=>v.type==='node').map(v=>v.node);
for(const kid of kids){
const c=pos.get(kid.id); if(!c) continue;
const cx=c.x, cy=c.y+C_H/2;
const mx=(px+cx)/2;
const sel=(node.id===selectedNodeId||kid.id===selectedNodeId);
paths+=`<path d="M${px},${py} C${mx},${py} ${mx},${cy} ${cx},${cy}" fill="none" stroke="${sel?'#4f6ef7':'#c5cae0'}" stroke-width="${sel?2:1.5}"/>`;
drawLines(kid);
}
}
for(const node of S.nodes) drawLines(node);
const svg=document.getElementById('canvasSvg');
svg.style.width=maxX+'px'; svg.style.height=maxY+'px';
svg.setAttribute('viewBox',`0 0 ${maxX} ${maxY}`);
svg.innerHTML=paths;
// 노드 카드
let cards='';
function renderCNode(node){
const p=pos.get(node.id); if(!p) return;
const sel=node.id===selectedNodeId;
const vals=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(' · ')||'비어있음';
cards+=`<div class="cnode${sel?' selected':''}" style="left:${p.x}px;top:${p.y}px" onclick="selectNode('${node.id}')">
<div class="cnode-head">
<span class="cnode-mark">${esc(node.mark||'·')}</span>
<span class="cnode-name" title="${esc(node.content||'')}">${esc(node.content||'이름 없음')}</span>
</div>
<div class="cnode-foot">
<span class="cnode-vals">${hint}</span>
<div class="cnode-btns">
<button class="cnode-btn" title="위로" onclick="event.stopPropagation();moveNode('${node.id}',-1)">↑</button>
<button class="cnode-btn" title="아래로" onclick="event.stopPropagation();moveNode('${node.id}',1)">↓</button>
<button class="cnode-btn" title="하위 노드 추가" onclick="event.stopPropagation();addChildNode('${node.id}')"></button>
<button class="cnode-btn del" title="삭제" onclick="event.stopPropagation();deleteFromCanvas('${node.id}')">✕</button>
</div>
</div>
</div>`;
for(const v of node.values) if(v.type==='node') renderCNode(v.node);
}
for(const node of S.nodes) renderCNode(node);
document.getElementById('canvasNodes').innerHTML=cards;
}
function renderNode(node, nested){
const valueItems=node.values.map(v=>renderValue(node.id,v)).join('');
function selectNode(nid){
selectedNodeId=nid;
renderCanvas();
renderDetail();
}
function addChildNode(parentNid){
const parent=nodeMap[parentNid]; if(!parent) return;
const child=mkNode();
parent.values.push(mkNodeVal(child));
selectedNodeId=child.id;
refresh();
}
function deleteFromCanvas(nid){
if(nid===selectedNodeId) selectedNodeId=null;
deleteNode(nid);
}
function renderDetail(){
const panel=document.getElementById('detailPanel'); if(!panel) return;
if(!selectedNodeId||!nodeMap[selectedNodeId]){
panel.innerHTML='<div class="detail-empty">← 노드를 클릭하면<br>상세 편집이 여기에 표시됩니다</div>';
return;
}
const node=nodeMap[selectedNodeId];
const valItems=node.values.map(v=>renderValue(node.id,v)).join('');
const typeOpts=VALUE_TYPES.map(t=>`<option value="${t.v}">${t.lbl}</option>`).join('');
const availSet=SETTING_OPTIONS.filter(o=>!(o.v in node.settings));
const setOpts=availSet.map(o=>`<option value="${o.v}">${o.lbl}</option>`).join('');
const settingItems=Object.entries(node.settings).map(([k,v])=>renderSetting(node.id,k,v)).join('');
return `
<div class="node-card${nested?' nested':''}" data-nid="${node.id}">
<div class="node-head${node.hidden?' is-hidden':''}">
panel.innerHTML=`
<div class="detail-hdr-lbl">📌 노드 편집</div>
<div class="detail-node-head">
<input class="mark-inp" title="구분자" value="${esc(node.mark??'')}"
oninput="nodeMap['${node.id}'].mark=this.value;updatePreview();save()">
oninput="nodeMap['${node.id}'].mark=this.value;updatePreview();save();renderCanvas()">
<input class="name-inp" placeholder="노드 이름" value="${esc(node.content??'')}"
oninput="nodeMap['${node.id}'].content=this.value;updatePreview();save()">
<button class="iBtn eye${node.hidden?' off':''}" title="숨기기 토글"
onclick="toggleHidden('${node.id}')">👁</button>
oninput="nodeMap['${node.id}'].content=this.value;updatePreview();save();renderCanvas()">
<button class="iBtn eye${node.hidden?' off':''}" title="숨기기 토글" onclick="toggleHidden('${node.id}')">👁</button>
<button class="iBtn mv" onclick="moveNode('${node.id}',-1)" title="위로">↑</button>
<button class="iBtn mv" onclick="moveNode('${node.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="deleteNode('${node.id}')" title="삭제">✕</button>
<button class="iBtn" onclick="deleteFromCanvas('${node.id}')" title="삭제">✕</button>
</div>
${!node.hidden?`
<div class="node-body">
<!-- 값 -->
<div class="detail-body">
<div class="body-sec">
<div class="body-sec-hdr">
<span class="body-sec-title">값</span>
<select class="add-sel"
onchange="addValue('${node.id}',this.value);this.selectedIndex=0">
<option value=""> 추가</option>
${typeOpts}
<select class="add-sel" onchange="addValue('${node.id}',this.value);this.selectedIndex=0">
<option value=""> 추가</option>${typeOpts}
</select>
</div>
${valueItems
?`<div class="body-sec-body">${valueItems}</div>`
:'<div class="empty-hint">값 없음 — 위에서 추가</div>'}
${valItems?`<div class="body-sec-body">${valItems}</div>`:'<div class="empty-hint">값 없음 — 위에서 추가</div>'}
</div>
<!-- 노드 설정 -->
<div class="body-sec">
<div class="body-sec-hdr">
<span class="body-sec-title">노드 설정</span>
${availSet.length
?`<select class="add-sel"
onchange="addSetting('${node.id}',this.value);this.selectedIndex=0">
<option value=""> 옵션 추가</option>
${setOpts}
</select>`:''}
${availSet.length?`<select class="add-sel" onchange="addSetting('${node.id}',this.value);this.selectedIndex=0">
<option value=""> 옵션 추가</option>${setOpts}</select>`:''}
</div>
<div class="body-sec-body">
${renderStructSettings(node)}
${settingItems}
<div class="body-sec-body">${renderStructSettings(node)}${settingItems}</div>
</div>
</div>
</div>`:''}
</div>`;
}
@@ -577,7 +718,17 @@ function renderValue(nid, val){
<button class="iBtn" onclick="removeValue('${val.id}')" title="삭제">✕</button>`;
if(val.type==='node'){
return `<div class="val-item val-node" data-vid="${val.id}">${renderNode(val.node,true)}</div>`;
const cn=val.node;
return `<div class="val-item" data-vid="${val.id}">
<div class="val-head">
<span class="val-type-badge">NODE</span>
<span style="flex:1;font-size:12px;padding:0 5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc((cn.mark||'')+' '+(cn.content||'이름 없음'))}</span>
<button class="iBtn" title="이 노드 선택" onclick="event.stopPropagation();selectNode('${cn.id}')">→</button>
<button class="iBtn mv" onclick="event.stopPropagation();moveValue('${val.id}',-1)" title="위로">↑</button>
<button class="iBtn mv" onclick="event.stopPropagation();moveValue('${val.id}',1)" title="아래로">↓</button>
<button class="iBtn" onclick="event.stopPropagation();removeValue('${val.id}')" title="삭제">✕</button>
</div>
</div>`;
}
const typeLbl=VALUE_TYPES.find(t=>t.v===val.type)?.lbl??val.type.toUpperCase();
@@ -910,9 +1061,7 @@ function switchTab(name){
// 초기화
// ════════════════════════════════════════════════════════
load();
rebuildMaps();
renderPanel();
updatePreview();
refresh();
document.getElementById('importModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeImport(); });
document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeImport(); });
</script>