--- name: 设置箭头局部拉伸 description: 开启或关闭箭头的局部拉伸功能(支持自定义固定点与拖拽点,带实时预览,可拖拽面板)。 author: ymjr version: 1.0.0 license: MIT usage: 当需要修改连线或箭头形状,但希望某些点保持固定(局部拉伸)时,运行该面板来定义固定点、拖拽点和中心点。 features: - 采用全闭包构建纯 HTML UI,零污染,支持自由拖拽 - Canvas 点位实时解析预览 - 允许自定义设置固定点索引与拖拽点索引 dependencies: - 依赖 Feature-ArrowHead-Resize-Engine 引擎进行拖拽拦截和形变计算 autorun: false --- /* ```javascript */ var locales = { zh: { notice_running: "🎯 拉伸节点配置面板已在运行中", notice_no_selection: "请先选中需要设置的连线或箭头!", notice_cancelled: "⭕ 已取消局部拉伸标记", panel_title: "🎯 配置局部拉伸节点", desc_text: "参考上方预览图设置点索引(逗号分隔,-1 代表最后一点)。", label_fixed: "固定点索引 (Fixed Points)", placeholder_fixed: "例如: 0, 1", label_drag: "拖拽点索引 (Draggable Points)", placeholder_drag: "例如: -1", label_center: "中心基准点 (Center Points)", placeholder_center: "例如: -1 (留空默认为 0,0)", btn_cancel: "取消", btn_submit: "保存并应用", preview_empty: "请选中一条连线或箭头以预览", notice_applied: "✨ 已应用局部拉伸配置" }, en: { notice_running: "🎯 Stretch Node Config Panel is already running", notice_no_selection: "Please select a line or arrow first!", notice_cancelled: "⭕ Local stretch mark cancelled", panel_title: "🎯 Configure Local Stretch Node", desc_text: "Set point index based on preview above (comma separated, -1 for last point).", label_fixed: "Fixed Points", placeholder_fixed: "e.g., 0, 1", label_drag: "Draggable Points", placeholder_drag: "e.g., -1", label_center: "Center Points", placeholder_center: "e.g., -1 (default is 0,0 if empty)", btn_cancel: "Cancel", btn_submit: "Save & Apply", preview_empty: "Please select a line or arrow to preview", notice_applied: "✨ Local stretch config applied" } }; (async () => { const { Notice } = ea.obsidian; const PANEL_ID = "ea-arrow-config-panel"; // 防重入锁:如果面板已经打开,不再重复创建 if (document.getElementById(PANEL_ID)) { new Notice(t("notice_running")); return; } const initialEls = ea.getViewSelectedElements(); if (initialEls.length === 0) { new Notice(t("notice_no_selection")); return; } // Toggle 逻辑:如果选中元素已经有高亮数据,则删除并退出 (仅当未打开面板时触发此逻辑) if (initialEls[0]?.customData?.fixedDragablePoints) { initialEls.forEach((el) => { if (el.customData) delete el.customData.fixedDragablePoints; }); ea.copyViewElementsToEAforEditing(initialEls); await ea.addElementsToView(); new Notice(t("notice_cancelled")); return; } // ========================================== // 1. 构建美观的悬浮 UI (支持拖拽) // ========================================== const panel = document.createElement('div'); panel.id = PANEL_ID; panel.style.cssText = ` position: fixed; top: 120px; left: calc(50% - 160px); width: 320px; background: var(--background-primary); border: 1px solid var(--background-modifier-border); box-shadow: var(--shadow-l); border-radius: 10px; font-family: var(--font-interface); color: var(--text-normal); z-index: 99999; display: flex; flex-direction: column; overflow: hidden; `; // 头部 (可拖拽区域) const header = document.createElement('div'); header.style.cssText = ` padding: 12px 16px; background: var(--background-secondary); cursor: grab; border-bottom: 1px solid var(--background-modifier-border); display: flex; justify-content: space-between; align-items: center; user-select: none; `; header.innerHTML = ` ${t("panel_title")} `; panel.appendChild(header); // 内容区 const content = document.createElement('div'); content.style.cssText = `padding: 16px; display: flex; flex-direction: column; gap: 14px;`; // 🌟 画布预览区 const canvasContainer = document.createElement('div'); canvasContainer.style.cssText = ` width: 100%; height: 120px; background: var(--background-primary-alt); border: 1px dashed var(--background-modifier-border); border-radius: 6px; display: flex; justify-content: center; align-items: center; position: relative; `; const previewCanvas = document.createElement('canvas'); previewCanvas.width = 280; previewCanvas.height = 110; previewCanvas.style.cssText = "max-width: 100%; max-height: 100%;"; canvasContainer.appendChild(previewCanvas); content.appendChild(canvasContainer); // 提示语 const desc = document.createElement('p'); desc.style.cssText = "margin: 0; font-size: 12px; color: var(--text-muted); line-height: 1.4;"; desc.innerHTML = t("desc_text"); content.appendChild(desc); // 输入框生成器 const createInputGroup = (id, label, placeholder) => { const div = document.createElement('div'); div.innerHTML = ` `; return div; }; content.appendChild(createInputGroup("ah-fixed", t("label_fixed"), t("placeholder_fixed"))); content.appendChild(createInputGroup("ah-drag", t("label_drag"), t("placeholder_drag"))); content.appendChild(createInputGroup("ah-center", t("label_center"), t("placeholder_center"))); panel.appendChild(content); // 底部操作区 const footer = document.createElement('div'); footer.style.cssText = `padding: 12px 16px; background: var(--background-secondary); border-top: 1px solid var(--background-modifier-border); display: flex; justify-content: flex-end; gap: 10px;`; footer.innerHTML = ` `; panel.appendChild(footer); document.body.appendChild(panel); // ========================================== // 2. Canvas 实时预览绘制逻辑 // ========================================== const drawPreview = (el) => { const ctx = previewCanvas.getContext('2d'); const cWidth = previewCanvas.width; const cHeight = previewCanvas.height; ctx.clearRect(0, 0, cWidth, cHeight); if (!el || !["line", "arrow"].includes(el.type) || !el.points || el.points.length === 0) { ctx.font = "12px sans-serif"; ctx.fillStyle = "#888"; ctx.textAlign = "center"; ctx.fillText(t("preview_empty"), cWidth / 2, cHeight / 2); return; } const points = el.points; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; points.forEach(p => { if (p[0] < minX) minX = p[0]; if (p[1] < minY) minY = p[1]; if (p[0] > maxX) maxX = p[0]; if (p[1] > maxY) maxY = p[1]; }); const pWidth = maxX - minX; const pHeight = maxY - minY; const padding = 20; // 边缘留白,防止点标记被截断 const scaleX = (cWidth - padding * 2) / (pWidth || 1); const scaleY = (cHeight - padding * 2) / (pHeight || 1); const scale = Math.min(scaleX, scaleY); // 计算居中偏移量 const offsetX = padding + (cWidth - padding * 2 - pWidth * scale) / 2 - minX * scale; const offsetY = padding + (cHeight - padding * 2 - pHeight * scale) / 2 - minY * scale; // 绘制连线轮廓 ctx.beginPath(); ctx.strokeStyle = "#888888"; ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.lineCap = "round"; points.forEach((p, i) => { const x = p[0] * scale + offsetX; const y = p[1] * scale + offsetY; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); // 绘制标记点与序号 ctx.font = "11px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; points.forEach((p, i) => { const x = p[0] * scale + offsetX; const y = p[1] * scale + offsetY; // 背景红点 ctx.beginPath(); ctx.fillStyle = "#ff4d4f"; ctx.arc(x, y, 8, 0, Math.PI * 2); ctx.fill(); // 内部白色数字 ctx.fillStyle = "#ffffff"; ctx.fillText(i.toString(), x, y + 1); }); }; // 挂载时立即渲染初始选中元素 drawPreview(initialEls[0]); // ========================================== // 3. 事件绑定与拖拽逻辑 // ========================================== const closePanel = () => { panel.remove(); clearInterval(autoUpdateTimer); // 清理定时器以防内存泄漏 }; document.getElementById('ah-close-btn').onclick = closePanel; document.getElementById('ah-cancel').onclick = closePanel; const parseVals = (str) => str ? str.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n)) : undefined; document.getElementById('ah-submit').onclick = async () => { const fixedVal = document.getElementById("ah-fixed").value; const dragVal = document.getElementById("ah-drag").value; const centerVal = document.getElementById("ah-center").value; const config = { fixedPointsIndex: parseVals(fixedVal) || [], dragablePointIndex: parseVals(dragVal) || [], centerIndex: parseVals(centerVal) }; const currentEls = ea.getViewSelectedElements(); currentEls.forEach((el) => { if(["line", "arrow"].includes(el.type)) { el.customData = { ...el.customData, fixedDragablePoints: config }; } }); ea.copyViewElementsToEAforEditing(currentEls); await ea.addElementsToView(); new Notice(t("notice_applied")); closePanel(); }; // 自由拖拽逻辑 let isDragging = false, startX, startY, initLeft, initTop; header.onmousedown = (e) => { // 忽略按钮点击引起的拖拽触发 if(e.target.closest('button')) return; isDragging = true; startX = e.clientX; startY = e.clientY; const rect = panel.getBoundingClientRect(); initLeft = rect.left; initTop = rect.top; header.style.cursor = "grabbing"; document.onmousemove = (moveEvent) => { if(!isDragging) return; moveEvent.preventDefault(); // 阻止默认行为,防止选中外部文本 let newX = initLeft + moveEvent.clientX - startX; let newY = initTop + moveEvent.clientY - startY; // 简单边界约束 newX = Math.max(0, Math.min(newX, window.innerWidth - 320)); newY = Math.max(0, Math.min(newY, window.innerHeight - panel.offsetHeight)); panel.style.left = newX + 'px'; panel.style.top = newY + 'px'; }; document.onmouseup = () => { isDragging = false; header.style.cursor = "grab"; document.onmousemove = null; document.onmouseup = null; }; }; // ========================================== // 4. 监听选中元素变化,实时更新预览图 // ========================================== let lastSelectedId = initialEls[0]?.id; const autoUpdateTimer = setInterval(() => { if (!document.getElementById(PANEL_ID)) { clearInterval(autoUpdateTimer); return; } const currentEls = ea.getViewSelectedElements(); const currentId = currentEls[0]?.id; if (currentId !== lastSelectedId) { lastSelectedId = currentId; drawPreview(currentEls[0]); } }, 500); })();