--- name: 高级连线引擎 (Advanced Connector) description: 提供UI界面,支持多种模式(一对一、一对多、串联)、方向、折线形态与FixedPoint固定点绑定的高级连线工具。 author: ymjr version: 1.0.0 license: MIT usage: 选中画板中的多个图形,运行此脚本,在弹出的UI中选择连接模式、方向和箭头样式。 features: - 现代化UI界面,自由选择连线模式、方向、线条类型与箭头样式 - 完美支持 FixedPoint 内部固定点精准锚定 - 支持智能折线 (Elbow)、曲线与直线的精确坐标计算 dependencies: [] autorun: false --- /* ```javascript */ var locales = { zh: { notice_select: "请至少选择两个元素!", ui_title: "🔗 高级连线引擎", mode_label: "连线模式", mode_1to1: "一对一连接 (选定两个)", mode_1toMany: "一对多连接 (首个至其余)", mode_serial: "串联连接 (依次相连)", dir_label: "连接方向", dir_LR: "从左到右 (L → R)", dir_RL: "从右到左 (R → L)", dir_TD: "从上到下 (T → D)", dir_BU: "从下到上 (B → U)", type_label: "连线样式", type_straight: "直线 (Straight)", type_elbow: "折线 (Elbow)", type_curve: "曲线 (Curve)", fixed_label: "启用固定点精确绑定 (Fixed Point)", start_arrow: "起点箭头", end_arrow: "终点箭头", arrow_none: "无 (None)", arrow_arrow: "箭头 (Arrow)", arrow_triangle: "三角 (Triangle)", arrow_dot: "圆点 (Dot)", arrow_bar: "竖线 (Bar)", btn_apply: "应用连接", btn_cancel: "取消", success: "✅ 连线已生成" }, en: { notice_select: "Please select at least two elements!", ui_title: "🔗 Advanced Connector", mode_label: "Connection Mode", mode_1to1: "1-to-1 (First two)", mode_1toMany: "1-to-Many (First to rest)", mode_serial: "Serial (Connect sequentially)", dir_label: "Direction", dir_LR: "Left to Right (L → R)", dir_RL: "Right to Left (R → L)", dir_TD: "Top to Bottom (T → D)", dir_BU: "Bottom to Top (B → U)", type_label: "Line Style", type_straight: "Straight", type_elbow: "Elbow", type_curve: "Curve", fixed_label: "Enable Fixed Point Binding", start_arrow: "Start Arrowhead", end_arrow: "End Arrowhead", arrow_none: "None", arrow_arrow: "Arrow", arrow_triangle: "Triangle", arrow_dot: "Dot", arrow_bar: "Bar", btn_apply: "Apply", btn_cancel: "Cancel", success: "✅ Connections generated" } }; // 预处理选中元素 const api = ea.getExcalidrawAPI(); const rawElements = ea.getViewSelectedElements().filter(el => !(el.type === "text" && el.containerId)); if (rawElements.length < 2) { new Notice(t("notice_select")); return; } const groups = ea.getMaximumGroups(rawElements); let els = groups.map(group => ea.getLargestElement(group)); if (els.length < 2) { new Notice(t("notice_select")); return; } // 确保 UI 唯一性 const uiId = 'ymjr-advanced-connector-ui'; if (document.getElementById(uiId)) { document.getElementById(uiId).remove(); } /** * 构建现代化用户界面 */ function buildUI() { return new Promise((resolve) => { const container = document.createElement('div'); container.id = uiId; // 使用 Obsidian 原生变量保持深浅色模式一致性 container.style.cssText = ` position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.4); z-index: 99999; display: flex; justify-content: center; align-items: center; backdrop-filter: blur(4px); opacity: 0; transition: opacity 0.2s ease-in-out; `; const panel = document.createElement('div'); panel.style.cssText = ` background: var(--background-primary); border: 1px solid var(--background-modifier-border); border-radius: 12px; padding: 24px; width: 420px; max-width: 90vw; box-shadow: 0 10px 30px rgba(0,0,0,0.3); font-family: var(--font-interface); display: flex; flex-direction: column; gap: 16px; transform: translateY(10px); transition: transform 0.2s ease-out; `; const title = `

${t("ui_title")}

`; const createSelect = (id, label, options) => `
`; const formHtml = `
${createSelect("ac-mode", t("mode_label"), { "one2one": t("mode_1to1"), "one2many": t("mode_1toMany"), "serial": t("mode_serial") })} ${createSelect("ac-dir", t("dir_label"), { "LR": t("dir_LR"), "RL": t("dir_RL"), "TD": t("dir_TD"), "BU": t("dir_BU") })} ${createSelect("ac-type", t("type_label"), { "straight": t("type_straight"), "elbow": t("type_elbow"), "curve": t("type_curve") })} ${createSelect("ac-start-arrow", t("start_arrow"), { "none": t("arrow_none"), "arrow": t("arrow_arrow"), "triangle": t("arrow_triangle"), "dot": t("arrow_dot"), "bar": t("arrow_bar") })} ${createSelect("ac-end-arrow", t("end_arrow"), { "triangle": t("arrow_triangle"), "arrow": t("arrow_arrow"), "none": t("arrow_none"), "dot": t("arrow_dot"), "bar": t("arrow_bar") })}
`; panel.innerHTML = title + formHtml; container.appendChild(panel); document.body.appendChild(container); // 动画显示 requestAnimationFrame(() => { container.style.opacity = '1'; panel.style.transform = 'translateY(0)'; }); const closeUI = (config = null) => { container.style.opacity = '0'; panel.style.transform = 'translateY(10px)'; setTimeout(() => { container.remove(); resolve(config); }, 200); }; document.getElementById('ac-cancel').onclick = () => closeUI(null); container.onclick = (e) => { if (e.target === container) closeUI(null); }; document.getElementById('ac-apply').onclick = () => { closeUI({ mode: document.getElementById('ac-mode').value, direction: document.getElementById('ac-dir').value, arrowType: document.getElementById('ac-type').value, enableFixedPoint: document.getElementById('ac-fixed').checked, startArrowHead: document.getElementById('ac-start-arrow').value, endArrowHead: document.getElementById('ac-end-arrow').value }); }; }); } /** * 核心坐标与连线样式更新逻辑(移植自参考代码) */ function applyLineStyle(line, sourceEl, targetEl, config) { const isHorizontal = config.direction === "LR" || config.direction === "RL"; // 基础起点 line.x = sourceEl.x + sourceEl.width; line.y = sourceEl.y + sourceEl.height / 2; if (config.direction === "RL") { line.x = sourceEl.x; } else if (config.direction === "TD") { line.x = sourceEl.x + sourceEl.width / 2; line.y = sourceEl.y + sourceEl.height; } else if (config.direction === "BU") { line.x = sourceEl.x + sourceEl.width / 2; line.y = sourceEl.y; } line.startBinding = { elementId: sourceEl.id, focus: 0, gap: 1 }; line.endBinding = { elementId: targetEl.id, focus: 0, gap: 1 }; // 固定点逻辑 if (config.enableFixedPoint) { line.startBinding.mode = "inside"; line.endBinding.mode = "inside"; if (config.direction === "LR") { line.startBinding.fixedPoint = [1.0, 0.5]; line.endBinding.fixedPoint = [0.0, 0.5]; } else if (config.direction === "RL") { line.startBinding.fixedPoint = [0.0, 0.5]; line.endBinding.fixedPoint = [1.0, 0.5]; } else if (config.direction === "TD") { line.startBinding.fixedPoint = [0.5, 1.0]; line.endBinding.fixedPoint = [0.5, 0.0]; } else if (config.direction === "BU") { line.startBinding.fixedPoint = [0.5, 0.0]; line.endBinding.fixedPoint = [0.5, 1.0]; } } else { delete line.startBinding.fixedPoint; delete line.startBinding.mode; delete line.endBinding.fixedPoint; delete line.endBinding.mode; } // 计算终点相对坐标 let childConnX = targetEl.x; let childConnY = targetEl.y + targetEl.height / 2; if (config.direction === "RL") { childConnX = targetEl.x + targetEl.width; } else if (config.direction === "TD") { childConnX = targetEl.x + targetEl.width / 2; childConnY = targetEl.y; } else if (config.direction === "BU") { childConnX = targetEl.x + targetEl.width / 2; childConnY = targetEl.y + targetEl.height; } const dx = childConnX - line.x; const dy = childConnY - line.y; // 路径点渲染逻辑 if (config.arrowType === "elbow") { line.elbowed = true; line.roundness = { type: 2 }; // 折线风格 if (isHorizontal) { line.points = [[0, 0], [dx / 2, 0], [dx / 2, dy], [dx, dy]]; } else { line.points = [[0, 0], [0, dy / 2], [dx, dy / 2], [dx, dy]]; } } else { line.elbowed = false; line.roundness = config.arrowType === "curve" ? { type: 3 } : null; // 曲线风格 const isAligned = isHorizontal ? Math.abs(dy) < 1 : Math.abs(dx) < 1; if (config.arrowType === "curve" && !isAligned) { if (isHorizontal) { line.points = [ [0, 0], [dx * 0.4, dy * 0.8905], [dx, dy] ]; } else { line.points = [ [0, 0], [dx * 0.8905, dy * 0.4], [dx, dy] ]; } } else { line.points = [ [0, 0], [dx, dy] ]; // 直线 } } } // 主干执行环境 (async () => { const config = await buildUI(); if (!config) return; // 用户点击取消 // 根据选择的方向重新排序元素数组,确保逻辑合理 if (["LR", "RL"].includes(config.direction)) { els.sort((a, b) => config.direction === "LR" ? a.x - b.x : b.x - a.x); } else { els.sort((a, b) => config.direction === "TD" ? a.y - b.y : b.y - a.y); } // 统一连线样式 const refEl = els[0]; ea.style.strokeColor = refEl.strokeColor; ea.style.strokeWidth = refEl.strokeWidth || 2; ea.style.strokeStyle = refEl.strokeStyle; ea.style.strokeSharpness = "round"; ea.style.roughness = refEl.roughness ?? 0; ea.copyViewElementsToEAforEditing(els); const sArrow = config.startArrowHead === "none" ? null : config.startArrowHead; const eArrow = config.endArrowHead === "none" ? null : config.endArrowHead; const generateLine = (source, target) => { // 先借用底层工具生成骨架 const arrowId = ea.connectObjects(source.id, null, target.id, null, { startArrowHead: sArrow, endArrowHead: eArrow, numberOfPoints: 2 }); const line = ea.elementsDict[arrowId]; // 应用你的高级数学重绘规则 applyLineStyle(line, source, target, config); return arrowId; }; let generatedArrows = []; // 根据模式分配连线拓扑 if (config.mode === "one2one") { generatedArrows.push(generateLine(els[0], els[1])); } else if (config.mode === "one2many") { const root = els[0]; for (let i = 1; i < els.length; i++) { generatedArrows.push(generateLine(root, els[i])); } } else if (config.mode === "serial") { for (let i = 0; i < els.length - 1; i++) { generatedArrows.push(generateLine(els[i], els[i+1])); } } // 将内容渲染回画板并保持当前所有元素的选中状态 await ea.addElementsToView(false, false, false); const elementsToSelect = els.concat(generatedArrows.map(id => ea.getElement(id))); ea.selectElementsInView(elementsToSelect); new Notice(t("success")); })();