ui: node drag-to-move, drag-to-child, and copy (v1.0)
- 드래그 핸들(⠿): 노드 카드 헤더 hover 시 표시, 마우스다운으로 드래그 시작 - 드래그 동작: - 상단 30% 위 → 해당 노드 앞에 삽입 (파란 상단 선) - 하단 30% 아래 → 해당 노드 뒤에 삽입 (파란 하단 선) - 중앙 40% → 해당 노드의 하위 노드로 편입 (초록 테두리) - 복사(⊕): 캔버스 버튼 + detail panel 버튼, 하위 구조 전체 deep clone Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
125
ui/builder.html
125
ui/builder.html
@@ -245,6 +245,13 @@ input,textarea,button,select{font-family:inherit}
|
||||
.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)}
|
||||
/* 드래그 */
|
||||
.cnode-drag{font-size:11px;color:var(--border);cursor:grab;flex-shrink:0;opacity:0;transition:opacity .1s;padding:0 2px;user-select:none;line-height:1}
|
||||
.cnode:hover .cnode-drag,.cnode.selected .cnode-drag{opacity:1}
|
||||
.cnode.dragging{opacity:0.3;pointer-events:none}
|
||||
.cnode.drop-child{border-color:var(--green)!important;box-shadow:0 0 0 3px rgba(22,163,74,.2)!important}
|
||||
.cnode.drop-before{box-shadow:0 -3px 0 0 var(--accent),var(--sh)!important}
|
||||
.cnode.drop-after{box-shadow:0 3px 0 0 var(--accent),var(--sh)!important}
|
||||
/* per_line 전용 UI */
|
||||
.perline-block{display:flex;align-items:center;gap:7px;padding:5px 6px;background:var(--surface-alt);border:1px solid var(--border);border-radius:5px}
|
||||
.perline-lbl{font-size:11px;font-weight:600;color:var(--muted);white-space:nowrap}
|
||||
@@ -286,7 +293,7 @@ input,textarea,button,select{font-family:inherit}
|
||||
<header class="app-header">
|
||||
<span style="font-size:18px">🏗️</span>
|
||||
<h1>CTStruct</h1>
|
||||
<span class="badge">v0.9</span>
|
||||
<span class="badge">v1.0</span>
|
||||
</header>
|
||||
|
||||
<nav class="main-nav">
|
||||
@@ -819,8 +826,9 @@ function renderCanvas(){
|
||||
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':''}${col?' collect':''}" style="left:${p.x}px;top:${p.y}px" onclick="selectNode('${node.id}')">
|
||||
cards+=`<div class="cnode${sel?' selected':''}${col?' collect':''}" data-nid="${node.id}" style="left:${p.x}px;top:${p.y}px" onclick="selectNode('${node.id}')">
|
||||
<div class="cnode-head">
|
||||
<span class="cnode-drag" onmousedown="startDrag(event,'${node.id}')" onclick="event.stopPropagation()" title="드래그로 이동">⠿</span>
|
||||
<span class="cnode-mark">${esc(node.mark||'·')}</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>':''}
|
||||
@@ -831,6 +839,7 @@ function renderCanvas(){
|
||||
<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();copyNode('${node.id}')">⊕</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>
|
||||
@@ -861,6 +870,117 @@ function deleteFromCanvas(nid){
|
||||
deleteNode(nid);
|
||||
}
|
||||
|
||||
// ── 노드 트리 직접 조작 ────────────────────────────────
|
||||
function removeNodeFromTree(nid){
|
||||
const ti=S.nodes.findIndex(n=>n.id===nid);
|
||||
if(ti!==-1) return S.nodes.splice(ti,1)[0];
|
||||
for(const n of Object.values(nodeMap)){
|
||||
const vi=n.values.findIndex(v=>v.type==='node'&&v.node.id===nid);
|
||||
if(vi!==-1) return n.values.splice(vi,1)[0].node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function insertAsChildOf(node, parentNid){
|
||||
const p=nodeMap[parentNid]; if(!p) return;
|
||||
p.values.push(mkNodeVal(node));
|
||||
}
|
||||
function insertBeforeNode(node, targetNid){
|
||||
const ti=S.nodes.findIndex(n=>n.id===targetNid);
|
||||
if(ti!==-1){ S.nodes.splice(ti,0,node); return; }
|
||||
for(const n of Object.values(nodeMap)){
|
||||
const vi=n.values.findIndex(v=>v.type==='node'&&v.node.id===targetNid);
|
||||
if(vi!==-1){ n.values.splice(vi,0,mkNodeVal(node)); return; }
|
||||
}
|
||||
}
|
||||
function insertAfterNode(node, targetNid){
|
||||
const ti=S.nodes.findIndex(n=>n.id===targetNid);
|
||||
if(ti!==-1){ S.nodes.splice(ti+1,0,node); return; }
|
||||
for(const n of Object.values(nodeMap)){
|
||||
const vi=n.values.findIndex(v=>v.type==='node'&&v.node.id===targetNid);
|
||||
if(vi!==-1){ n.values.splice(vi+1,0,mkNodeVal(node)); return; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 노드 복사 ──────────────────────────────────────────
|
||||
function deepCloneNode(n){
|
||||
function clone(node){
|
||||
const c=mkNode(node.mark,node.content);
|
||||
c.hidden=node.hidden??false;
|
||||
c.nameNewline=node.nameNewline??true;
|
||||
c.settings=JSON.parse(JSON.stringify(node.settings||{}));
|
||||
for(const v of node.values||[]){
|
||||
if(v.type==='node') c.values.push(mkNodeVal(clone(v.node)));
|
||||
else{ const sv=mkScalarVal(v.type); sv.data=v.data??''; c.values.push(sv); }
|
||||
}
|
||||
return c;
|
||||
}
|
||||
return clone(n);
|
||||
}
|
||||
function copyNode(nid){
|
||||
rebuildMaps();
|
||||
const n=nodeMap[nid]; if(!n) return;
|
||||
const cloned=deepCloneNode(n);
|
||||
insertAfterNode(cloned,nid);
|
||||
selectedNodeId=cloned.id;
|
||||
refresh();
|
||||
}
|
||||
|
||||
// ── 드래그로 이동 ──────────────────────────────────────
|
||||
function startDrag(e, nid){
|
||||
if(e.button!==0) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const srcEl=document.querySelector(`.cnode[data-nid="${nid}"]`); if(!srcEl) return;
|
||||
const rect=srcEl.getBoundingClientRect();
|
||||
|
||||
const ghost=srcEl.cloneNode(true);
|
||||
ghost.style.cssText=`width:${rect.width}px;position:fixed;left:${rect.left}px;top:${rect.top}px;pointer-events:none;z-index:9999;opacity:.85;transform:rotate(1.5deg) scale(1.04);box-shadow:0 12px 40px rgba(0,0,0,.25);transition:none`;
|
||||
ghost.classList.remove('selected','dragging');
|
||||
document.body.appendChild(ghost);
|
||||
srcEl.classList.add('dragging');
|
||||
|
||||
const state={ nid, ghost, ox:e.clientX-rect.left, oy:e.clientY-rect.top, drop:null };
|
||||
|
||||
function onMove(e){
|
||||
ghost.style.left=(e.clientX-state.ox)+'px';
|
||||
ghost.style.top =(e.clientY-state.oy)+'px';
|
||||
document.querySelectorAll('.cnode.drop-child,.cnode.drop-before,.cnode.drop-after')
|
||||
.forEach(el=>el.classList.remove('drop-child','drop-before','drop-after'));
|
||||
state.drop=null;
|
||||
for(const el of document.querySelectorAll('.cnode:not(.dragging)')){
|
||||
const r=el.getBoundingClientRect();
|
||||
if(e.clientX>=r.left&&e.clientX<=r.right&&e.clientY>=r.top&&e.clientY<=r.bottom){
|
||||
const rel=(e.clientY-r.top)/r.height;
|
||||
const mode=rel<0.3?'before':rel>0.7?'after':'child';
|
||||
el.classList.add('drop-'+mode);
|
||||
state.drop={nid:el.dataset.nid, mode};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onUp(){
|
||||
ghost.remove();
|
||||
document.querySelectorAll('.cnode').forEach(el=>el.classList.remove('dragging','drop-child','drop-before','drop-after'));
|
||||
document.removeEventListener('mousemove',onMove);
|
||||
document.removeEventListener('mouseup',onUp);
|
||||
if(state.drop&&state.drop.nid!==state.nid){
|
||||
rebuildMaps();
|
||||
const node=removeNodeFromTree(state.nid);
|
||||
if(node){
|
||||
rebuildMaps();
|
||||
if(state.drop.mode==='child') insertAsChildOf(node, state.drop.nid);
|
||||
else if(state.drop.mode==='before') insertBeforeNode(node, state.drop.nid);
|
||||
else insertAfterNode(node, state.drop.nid);
|
||||
selectedNodeId=state.nid;
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove',onMove);
|
||||
document.addEventListener('mouseup',onUp);
|
||||
}
|
||||
|
||||
function renderDetail(){
|
||||
const panel=document.getElementById('detailPanel'); if(!panel) return;
|
||||
if(!selectedNodeId||!nodeMap[selectedNodeId]){
|
||||
@@ -883,6 +1003,7 @@ function renderDetail(){
|
||||
<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 mv" onclick="copyNode('${node.id}')" title="복사">⊕</button>
|
||||
<button class="iBtn" onclick="deleteFromCanvas('${node.id}')" title="삭제">✕</button>
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
|
||||
Reference in New Issue
Block a user