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:
2026-06-05 18:01:55 +09:00
parent 41c73fecd5
commit 2d6be54c02

View File

@@ -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{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.collect{background:var(--accent-bg);color:var(--accent)}
.ftpl-role.fixed{background:var(--surface-alt);color:var(--muted);border:1px solid var(--border)} .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 */ /* 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-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} .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"> <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.9</span> <span class="badge">v1.0</span>
</header> </header>
<nav class="main-nav"> <nav class="main-nav">
@@ -819,8 +826,9 @@ function renderCanvas(){
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':''}${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"> <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-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>':''} ${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"> <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();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" title="하위 노드 추가" onclick="event.stopPropagation();addChildNode('${node.id}')"></button>
<button class="cnode-btn del" title="삭제" onclick="event.stopPropagation();deleteFromCanvas('${node.id}')">✕</button> <button class="cnode-btn del" title="삭제" onclick="event.stopPropagation();deleteFromCanvas('${node.id}')">✕</button>
</div> </div>
@@ -861,6 +870,117 @@ function deleteFromCanvas(nid){
deleteNode(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(){ function renderDetail(){
const panel=document.getElementById('detailPanel'); if(!panel) return; const panel=document.getElementById('detailPanel'); if(!panel) return;
if(!selectedNodeId||!nodeMap[selectedNodeId]){ 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 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="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> <button class="iBtn" onclick="deleteFromCanvas('${node.id}')" title="삭제">✕</button>
</div> </div>
<div class="detail-body"> <div class="detail-body">