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:
257
ui/builder.html
257
ui/builder.html
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') }
|
||||
// 입력값의 \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>
|
||||
|
||||
Reference in New Issue
Block a user