/* ---- 拖线 / 拖卡 / 平移(mousedown 分流 + document 跟随;bindContactsUI 统一绑定) ---- */ let dragLine = null, dragCard = null, panning = null; function startDragLine(e, fromId, dir) { const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('class', 'edge-line edge-pending'); path.setAttribute('fill', 'none'); edgeSvg.appendChild(path); dragLine = { from: fromId, dir: dir || 'down', path: path }; updateDragLine(e); } function updateDragLine(e) { const p = posOf(dragLine.from); if (!p) return; const sy = dragLine.dir === 'up' ? p.y : p.y + CARD_H; const pt = toCanvas(e.clientX, e.clientY); dragLine.path.setAttribute('d', 'M ' + (p.x + CARD_W / 2) + ' ' + sy + ' L ' + pt.x + ' ' + pt.y); canvasInner.querySelectorAll('.gcard.link-target').forEach(el => el.classList.remove('link-target')); const t = document.elementFromPoint(e.clientX, e.clientY); const card = t && t.closest ? t.closest('.gcard') : null; if (card && !card.classList.contains('company') && card.dataset.id && card.dataset.id !== dragLine.from) card.classList.add('link-target'); } function tryConnect(aId, bId) { if (aId === bId) { toast('不允许连接自身(' + cname(aId) + ')', 'err'); return; } if (aId === COMPANY_ID || bId === COMPANY_ID) { toast('不允许为公司节点新增或删除关系', 'err'); return; } if (hasPair(effectiveEdges(), aId, bId) || hasPair(effectiveEdges(), bId, aId)) { toast('已存在连线:' + cname(aId) + ' ↔ ' + cname(bId) + '(蓝色实线即是),换一对联系人试试', 'err'); return; } const a = byId(aId), b = byId(bId); if (!a || !b) return; if (a.level == null || b.level == null) { toast('「' + cname(a.level == null ? aId : bId) + '」未设置等级,请先将其拖入等级格子完成定级再连线'); return; } commitDirectionalEdge(aId, bId); } /* 两端层级已齐:按层级自动定方向(高者为上级;同级不连)+ 单父约束 → 落 pending */ function commitDirectionalEdge(aId, bId) { const a = byId(aId), b = byId(bId); if (!a || !b) return false; if (a.level === b.level) { toast('同级(层级相同)之间不建立上下级连线', 'err'); return false; } const parentId = a.level > b.level ? aId : bId, childId = a.level > b.level ? bId : aId; const exParent = effectiveEdges().find(e => e.child === childId); if (exParent) { toast(cname(childId) + ' 已有上级(' + cname(exParent.parent) + '),一个节点最多一个上级,请先删除原连线', 'err'); return false; } commitPendingEdge(parentId, childId); return true; } function commitPendingEdge(parentId, childId) { G.pendingAdds.push({ parent: parentId, child: childId }); G.cycleHighlight = null; addLog('关系', '新增连线(未保存):' + cname(parentId) + ' → ' + cname(childId)); renderGraph(); renderGraphState(); } function graphMouseDown(e) { if (e.button !== 0) return; const dot = e.target.closest('.dot'); if (dot && G.editing) { const gc = dot.closest('.gcard'); if (!gc) return; if (dot.classList.contains('dot-bottom')) { e.preventDefault(); startDragLine(e, gc.dataset.id, 'down'); return; } if (dot.classList.contains('dot-top')) { e.preventDefault(); startDragLine(e, gc.dataset.id, 'up'); return; } } const card = e.target.closest('.gcard'); if (card && G.editing && !card.classList.contains('company') && card.dataset.id) { const pt = toCanvas(e.clientX, e.clientY), p = G.cardPos[card.dataset.id]; if (!p) return; dragCard = { id: card.dataset.id, dx: pt.x - p.x, dy: pt.y - p.y }; e.preventDefault(); return; } if (!card) { panning = { x: e.clientX, y: e.clientY, px: G.view.panX, py: G.view.panY }; wrap.classList.add('panning'); } } function graphMouseMove(e) { if (dragLine) { updateDragLine(e); return; } if (dragCard) { const pt = toCanvas(e.clientX, e.clientY); const p = G.cardPos[dragCard.id]; if (!p) return; p.x = Math.max(0, Math.min(2400 - CARD_W, pt.x - dragCard.dx)); p.y = Math.max(0, Math.min(1400 - CARD_H, pt.y - dragCard.dy)); const el = canvasInner.querySelector('.gcard[data-id="' + dragCard.id + '"]'); if (el) { el.style.left = p.x + 'px'; el.style.top = p.y + 'px'; } canvasInner.querySelectorAll('.lv-row.drop-target').forEach(x => x.classList.remove('drop-target')); if (G.editing) { const t = rowAt(pt.x, pt.y); if (t.kind === 'lv') { const row = canvasInner.querySelector('.lv-row[data-lv="' + t.lv + '"]'); if (row) row.classList.add('drop-target'); } } renderEdgesOnly(); return; } if (panning) { G.view.panX = panning.px + (e.clientX - panning.x); G.view.panY = panning.py + (e.clientY - panning.y); applyTransform(); } } function graphMouseUp(e) { if (dragLine) { const t = document.elementFromPoint(e.clientX, e.clientY); const card = t && t.closest ? t.closest('.gcard') : null; if (card && card.classList.contains('company')) toast('不允许为公司节点新增或删除关系', 'err'); else if (card && card.dataset.id && card.dataset.id !== dragLine.from) tryConnect(dragLine.from, card.dataset.id); dragLine.path.remove(); dragLine = null; canvasInner.querySelectorAll('.gcard.link-target').forEach(el => el.classList.remove('link-target')); } if (dragCard) { const pt = toCanvas(e.clientX, e.clientY); const t = rowAt(pt.x, pt.y); if (t.kind === 'lv' || t.kind === 'unrec') tryLevelDrop(dragCard.id, t); else if (t.kind === 'company') toast('公司行不可放置联系人卡片'); canvasInner.querySelectorAll('.lv-row.drop-target').forEach(x => x.classList.remove('drop-target')); } dragCard = null; if (panning) { panning = null; wrap.classList.remove('panning'); } } function graphWheel(e) { e.preventDefault(); zoomAt(e.deltaY < 0 ? 1.12 : 0.9, e.clientX, e.clientY); }