--- name: 折线箭头坐标精调面板 description: 交互式悬浮面板与画布手柄:直观选中多点折线/箭头的任意边或顶点,支持画布直接拖拽、方向键/十字键微调、坐标精设与拐点增删。 author: ymjr version: 1.0.0 license: MIT usage: 运行脚本打开/关闭控制面板,在画布中选中任意折线或箭头,即可在画布手柄或面板预览图上点击边/点进行上下左右移动、坐标精调或加减拐点。 features: - 双向交互:支持在画布实时高亮手柄上直接拖拽边/顶点,也支持在面板 SVG 预览图中点击选择 - 按边调整:平移整条边(同时移动两端点),自动保持相邻直角正交不斜 - 首尾锚点保护:移动连接图形的起始/结束边时,自动折出干净的 90° 阶梯过渡,不脱离绑定元素 - 精准控制:支持步长预设、十字键/键盘方向键微调、场景坐标精确输入、水平/垂直拉直与居中对齐 - 拓扑工具:支持一键折出 U 型绕行弯、插入中点、清理共线冗余点、切换自由折线模式 - 支持原生 Ctrl+Z 撤销与面板内一键回退,多视图/弹出窗口隔离互不干扰 dependencies: [] autorun: false --- /* ```javascript */ var locales = { zh: { panel_title: "🎯 折线箭头精调", notice_no_view: "⚠️ 请先打开一个 Excalidraw 画布", notice_opened: "✨ 折线箭头精调面板已开启", notice_closed: "🔌 折线箭头精调面板已关闭", notice_no_arrow: "请在画布上选中一条箭头或连线", notice_need_2pts: "连线至少需要 2 个点", notice_undo_empty: "暂无可撤销的面板操作", notice_undone: "↩️ 已撤销上一步调整", notice_simplified: "🧹 已清理 {count} 个冗余点", notice_no_redundant: "路径已是最简,无需清理", notice_jog_added: "📐 已折出绕行弯", notice_split_added: "➕ 已插入中点", notice_pt_deleted: "🗑️ 已删除顶点", notice_unelbowed: "🔓 已转为自由多点折线", empty_hint: "请在画布中点击选中任意折线 / 箭头\n支持直接拖拽画布上的边数字手柄", mode_edge: "按边调整", mode_point: "按点调整", btn_handles_title: "开关画布直接拖拽手柄", btn_kbd_title: "开关方向键直接微调 (开启后 ↑↓←→ 直接移动当前边/点,Alt+方向键始终可用)", btn_undo_title: "撤销上一步", protect_endpoints: "首尾锚点保护 (自动折出拐角)", step_label: "步长(px)", seg_info: "边 E{idx} · {orient} · 长 {len}px", pt_info: "点 P{idx} / {total}", orient_h: "↔ 水平", orient_v: "↕ 垂直", orient_d: "⤢ 斜线", orient_z: "• 重合", center_btn: "居中", center_title: "在前后邻边之间居中对齐", ortho_h: "拉平水平", ortho_v: "拉直垂直", align_prev_x: "对齐前点 X", align_prev_y: "对齐前点 Y", align_next_x: "对齐后点 X", align_next_y: "对齐后点 Y", coord_y: "边 Y 坐标", coord_x: "边 X 坐标", coord_len: "边长", pt_x: "X 坐标", pt_y: "Y 坐标", tool_jog: "折出 U 型弯", tool_split: "插入中点", tool_del_pt: "删除当前点", tool_simplify: "清理冗余点", tool_unelbow: "转自由折线", badge_elbow: "自动折线", badge_free: "自由折线", badge_points: "{pts}点 · {segs}边", multi_label: "已选 {n} 条:" }, en: { panel_title: "🎯 Arrow Path Editor", notice_no_view: "⚠️ Please open an Excalidraw canvas first", notice_opened: "✨ Arrow Path Editor opened", notice_closed: "🔌 Arrow Path Editor closed", notice_no_arrow: "Select an arrow or line on the canvas", notice_need_2pts: "A line needs at least 2 points", notice_undo_empty: "Nothing to undo in panel history", notice_undone: "↩️ Undone last adjustment", notice_simplified: "🧹 Simplified {count} redundant point(s)", notice_no_redundant: "Path is already minimal", notice_jog_added: "📐 Added orthogonal detour jog", notice_split_added: "➕ Inserted midpoint", notice_pt_deleted: "🗑️ Deleted point", notice_unelbowed: "🔓 Converted to free polyline", empty_hint: "Click any arrow or line on the canvas\nDrag edge pills on canvas or use the pad below", mode_edge: "Edge", mode_point: "Point", btn_handles_title: "Toggle on-canvas interactive handles", btn_kbd_title: "Toggle arrow-key capture (↑↓←→ nudges selected edge/point; Alt+Arrows always works)", btn_undo_title: "Undo last edit", protect_endpoints: "Protect end anchors (auto-jog)", step_label: "Step (px)", seg_info: "Edge E{idx} · {orient} · {len}px", pt_info: "Point P{idx} / {total}", orient_h: "↔ Horiz", orient_v: "↕ Vert", orient_d: "⤢ Diag", orient_z: "• Zero", center_btn: "Center", center_title: "Center between adjacent segments", ortho_h: "Flatten H", ortho_v: "Straighten V", align_prev_x: "Align Prev X", align_prev_y: "Align Prev Y", align_next_x: "Align Next X", align_next_y: "Align Next Y", coord_y: "Edge Y", coord_x: "Edge X", coord_len: "Length", pt_x: "X", pt_y: "Y", tool_jog: "Add U-Jog", tool_split: "Split Mid", tool_del_pt: "Delete Pt", tool_simplify: "Simplify", tool_unelbow: "Un-elbow", badge_elbow: "Elbow", badge_free: "Polyline", badge_points: "{pts} pts · {segs} edges", multi_label: "{n} lines:" } }; locales["zh-cn"] = locales.zh; locales["zh-tw"] = locales.zh; (function () { const { Notice } = ea.obsidian; const view = ea.targetView; const api = view?.excalidrawAPI || ea.getExcalidrawAPI?.(); if (!view || !api) { new Notice(t("notice_no_view")); return; } const hostEl = view.excalidrawContainer || view.containerEl?.querySelector(".excalidraw") || view.containerEl; if (!hostEl) { new Notice(t("notice_no_view")); return; } const doc = hostEl.ownerDocument || document; const win = doc.defaultView || window; const PANEL_ID = "ymjr-arrow-segment-editor-panel"; const OVERLAY_ID = "ymjr-arrow-segment-editor-overlay"; const STYLE_ID = "ymjr-arrow-segment-editor-style"; const PLUGIN_STORE_KEY = "_ymjr_arrowSegmentEditors"; const plugin = ExcalidrawAutomate.plugin || {}; ExcalidrawAutomate.plugin = plugin; if (!plugin[PLUGIN_STORE_KEY]) { plugin[PLUGIN_STORE_KEY] = new WeakMap(); } const viewRegistry = plugin[PLUGIN_STORE_KEY]; // Toggle: if already open on this view, close cleanly and exit const existing = viewRegistry.get(view); if (existing && typeof existing.destroy === "function") { existing.destroy(); viewRegistry.delete(view); new Notice(t("notice_closed")); return; } const hostStyle = win.getComputedStyle(hostEl); if (hostStyle.position === "static") { hostEl.style.position = "relative"; } // ========================================== // Geometry & Excalidraw Linear Helpers // ========================================== const EPS = 0.5; const round2 = (v) => Math.round(v * 100) / 100; const round4 = (v) => Math.round(v * 10000) / 10000; const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const randomNonce = () => Math.floor(Math.random() * 2147483647); function getLinearBoundingBoxLocal(points) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const [x, y] of points) { if (x < minX) minX = x; if (y < minY) minY = y; if (x > maxX) maxX = x; if (y > maxY) maxY = y; } if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0, w: 0, h: 0 }; return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY }; } function getAbsolutePoints(el) { const pts = el.points || [[0, 0], [0, 0]]; const angle = el.angle || 0; if (Math.abs(angle) < 1e-5) { return pts.map(([px, py]) => [el.x + px, el.y + py]); } const { minX, minY, w, h } = getLinearBoundingBoxLocal(pts); const cx = el.x + minX + w / 2; const cy = el.y + minY + h / 2; const cos = Math.cos(angle); const sin = Math.sin(angle); return pts.map(([px, py]) => { const gx = el.x + px - cx; const gy = el.y + py - cy; return [cx + gx * cos - gy * sin, cy + gx * sin + gy * cos]; }); } function getSegmentOrientation(p1, p2) { const dx = Math.abs(p2[0] - p1[0]); const dy = Math.abs(p2[1] - p1[1]); if (dx <= EPS && dy <= EPS) return "zero"; if (dy <= EPS) return "H"; if (dx <= EPS) return "V"; return "D"; } function getPointAtHalfLength(absPts) { if (!absPts.length) return [0, 0]; if (absPts.length === 1) return absPts[0]; let total = 0; const segLens = []; for (let i = 0; i < absPts.length - 1; i++) { const len = Math.hypot(absPts[i + 1][0] - absPts[i][0], absPts[i + 1][1] - absPts[i][1]); segLens.push(len); total += len; } if (total < 1e-3) return absPts[0]; let target = total / 2; for (let i = 0; i < segLens.length; i++) { if (target <= segLens[i] || i === segLens.length - 1) { const ratio = segLens[i] > 1e-6 ? target / segLens[i] : 0; return [ absPts[i][0] + (absPts[i + 1][0] - absPts[i][0]) * ratio, absPts[i][1] + (absPts[i + 1][1] - absPts[i][1]) * ratio ]; } target -= segLens[i]; } return absPts[Math.floor(absPts.length / 2)]; } function buildUpdatedFixedSegments(points) { if (!Array.isArray(points) || points.length < 4) return []; const segs = []; for (let i = 1; i < points.length - 2; i++) { segs.push({ start: [points[i][0], points[i][1]], end: [points[i + 1][0], points[i + 1][1]], index: i + 1 }); } return segs; } function updateBindingFixedPoint(binding, absPt, elementsMap) { if (!binding || !binding.elementId) return binding; const target = elementsMap.get(binding.elementId); if (!target) return binding; const fx = target.width > 0 ? clamp((absPt[0] - target.x) / target.width, 0, 1) : 0.5; const fy = target.height > 0 ? clamp((absPt[1] - target.y) / target.height, 0, 1) : 0.5; return { ...binding, fixedPoint: [round4(fx), round4(fy)] }; } function cloneLinearWithAbsPoints(el, rawAbsPts, elementsMap, options = {}) { const absPts = rawAbsPts.map(([x, y]) => [round2(x), round2(y)]); const origin = absPts[0]; const points = absPts.map(([ax, ay], idx) => idx === 0 ? [0, 0] : [round2(ax - origin[0]), round2(ay - origin[1])] ); const box = getLinearBoundingBoxLocal(points); const nextEl = { ...el, x: round2(origin[0]), y: round2(origin[1]), width: round2(box.w), height: round2(box.h), angle: 0, points, version: (el.version || 0) + 1, versionNonce: randomNonce(), updated: Date.now() }; if (options.unelbow) { nextEl.elbowed = false; nextEl.fixedSegments = null; nextEl.startIsSpecial = null; nextEl.endIsSpecial = null; } else if (nextEl.elbowed) { nextEl.fixedSegments = buildUpdatedFixedSegments(points); if (points.length !== (el.points?.length || 0)) { nextEl.startIsSpecial = null; nextEl.endIsSpecial = null; } } const oldAbs = getAbsolutePoints(el); const startMoved = Math.hypot(absPts[0][0] - oldAbs[0][0], absPts[0][1] - oldAbs[0][1]) > 0.01; const endMoved = Math.hypot( absPts[absPts.length - 1][0] - oldAbs[oldAbs.length - 1][0], absPts[absPts.length - 1][1] - oldAbs[oldAbs.length - 1][1] ) > 0.01; if (startMoved && nextEl.startBinding) { nextEl.startBinding = updateBindingFixedPoint(nextEl.startBinding, absPts[0], elementsMap); } if (endMoved && nextEl.endBinding) { nextEl.endBinding = updateBindingFixedPoint(nextEl.endBinding, absPts[absPts.length - 1], elementsMap); } return { nextEl, absPts }; } function computeMovedSegmentAbsPoints(origAbsPts, segIdx, dx, dy, protectEndpoints) { const n = origAbsPts.length; const pts = origAbsPts.map((p) => [p[0], p[1]]); const nextSegIdx = segIdx; if (!protectEndpoints) { pts[segIdx][0] += dx; pts[segIdx][1] += dy; pts[segIdx + 1][0] += dx; pts[segIdx + 1][1] += dy; return { pts, nextSegIdx }; } const touchesStart = segIdx === 0; const touchesEnd = segIdx === n - 2; const pStart = pts[0]; const pEnd = pts[n - 1]; const orient = getSegmentOrientation(pts[segIdx], pts[segIdx + 1]); const isParallel = (orient === "H" && Math.abs(dy) < 1e-4) || (orient === "V" && Math.abs(dx) < 1e-4); if (touchesStart && touchesEnd) { if (isParallel) return { pts, nextSegIdx }; const shifted0 = [pStart[0] + dx, pStart[1] + dy]; const shifted1 = [pEnd[0] + dx, pEnd[1] + dy]; return { pts: [pStart, shifted0, shifted1, pEnd], nextSegIdx: 1 }; } if (touchesStart) { if (isParallel) { pts[1][0] += dx; pts[1][1] += dy; return { pts, nextSegIdx }; } const p1 = pts[1]; const result = [ pStart, [pStart[0] + dx, pStart[1] + dy], [p1[0] + dx, p1[1] + dy], ...pts.slice(2) ]; return { pts: result, nextSegIdx: 1 }; } if (touchesEnd) { if (isParallel) { pts[n - 2][0] += dx; pts[n - 2][1] += dy; return { pts, nextSegIdx }; } const pPrev = pts[n - 2]; const result = [ ...pts.slice(0, n - 2), [pPrev[0] + dx, pPrev[1] + dy], [pEnd[0] + dx, pEnd[1] + dy], pEnd ]; return { pts: result, nextSegIdx: segIdx }; } pts[segIdx][0] += dx; pts[segIdx][1] += dy; pts[segIdx + 1][0] += dx; pts[segIdx + 1][1] += dy; return { pts, nextSegIdx }; } // ========================================== // Editor State // ========================================== const state = { activeId: null, candidateIds: [], mode: "edge", // "edge" | "point" segIdx: 0, ptIdx: 0, step: 10, protectEndpoints: true, snapToStep: true, showHandles: true, captureArrows: false, undoStack: [], lastRenderSig: "", lastOverlaySig: "", dragState: null }; // ========================================== // Scene Commit Helpers // ========================================== function getSceneElementsSafe() { return api.getSceneElementsIncludingDeleted?.() || api.getSceneElements() || []; } function getActiveArrow() { if (!state.activeId) return null; const els = api.getSceneElements() || []; return els.find((e) => e.id === state.activeId && !e.isDeleted && (e.type === "arrow" || e.type === "line")) || null; } function findBoundText(arrowEl, sceneEls) { if (!arrowEl?.boundElements?.length) return null; const ref = arrowEl.boundElements.find((b) => b.type === "text"); if (!ref) return null; return sceneEls.find((e) => e.id === ref.id && !e.isDeleted && e.type === "text") || null; } function pushUndoSnapshot(arrowEl) { const sceneEls = api.getSceneElements() || []; const boundText = findBoundText(arrowEl, sceneEls); state.undoStack.push({ arrowId: arrowEl.id, arrowSnapshot: JSON.parse(JSON.stringify(arrowEl)), boundTextSnapshot: boundText ? JSON.parse(JSON.stringify(boundText)) : null }); if (state.undoStack.length > 40) state.undoStack.shift(); } function commitAbsPoints(arrowEl, newAbsPts, opts = {}) { const captureUpdate = opts.captureUpdate || "IMMEDIATELY"; const pushUndo = opts.pushUndo !== false; const allSceneEls = getSceneElementsSafe(); const elementsMap = new Map(allSceneEls.filter((e) => !e.isDeleted).map((e) => [e.id, e])); if (pushUndo) { pushUndoSnapshot(arrowEl); } const { nextEl, absPts } = cloneLinearWithAbsPoints(arrowEl, newAbsPts, elementsMap, opts); const boundRef = nextEl.boundElements?.find((b) => b.type === "text"); let nextTextEl = null; if (boundRef) { const textEl = elementsMap.get(boundRef.id); if (textEl && textEl.type === "text") { const [midX, midY] = getPointAtHalfLength(absPts); nextTextEl = { ...textEl, x: round2(midX - (textEl.width || 0) / 2), y: round2(midY - (textEl.height || 0) / 2), version: (textEl.version || 0) + 1, versionNonce: randomNonce(), updated: Date.now() }; } } const nextSceneEls = allSceneEls.map((el) => { if (el.id === nextEl.id) return nextEl; if (nextTextEl && el.id === nextTextEl.id) return nextTextEl; return el; }); view.updateScene({ elements: nextSceneEls, captureUpdate }); if (captureUpdate === "IMMEDIATELY" && typeof view.setDirty === "function") { view.setDirty(); } return nextEl; } function performUndo() { const snap = state.undoStack.pop(); if (!snap) { new Notice(t("notice_undo_empty")); return; } const allSceneEls = getSceneElementsSafe(); const restoredArrow = { ...snap.arrowSnapshot, version: (snap.arrowSnapshot.version || 0) + 1, versionNonce: randomNonce(), updated: Date.now() }; const restoredText = snap.boundTextSnapshot ? { ...snap.boundTextSnapshot, version: (snap.boundTextSnapshot.version || 0) + 1, versionNonce: randomNonce(), updated: Date.now() } : null; const nextEls = allSceneEls.map((el) => { if (el.id === restoredArrow.id) return restoredArrow; if (restoredText && el.id === restoredText.id) return restoredText; return el; }); view.updateScene({ elements: nextEls, captureUpdate: "IMMEDIATELY" }); if (typeof view.setDirty === "function") view.setDirty(); state.activeId = restoredArrow.id; clampIndices(restoredArrow.points.length); new Notice(t("notice_undone")); refreshAllUI(true); } function clampIndices(ptCount) { const maxPt = Math.max(0, ptCount - 1); const maxSeg = Math.max(0, ptCount - 2); state.ptIdx = clamp(state.ptIdx, 0, maxPt); state.segIdx = clamp(state.segIdx, 0, maxSeg); } // ========================================== // Path Mutation Actions // ========================================== function nudgeSelection(dx, dy) { const el = getActiveArrow(); if (!el) return new Notice(t("notice_no_arrow")); const absPts = getAbsolutePoints(el); clampIndices(absPts.length); if (state.mode === "edge") { const { pts, nextSegIdx } = computeMovedSegmentAbsPoints( absPts, state.segIdx, dx, dy, state.protectEndpoints ); state.segIdx = nextSegIdx; commitAbsPoints(el, pts); } else { const pts = absPts.map((p) => [p[0], p[1]]); pts[state.ptIdx][0] += dx; pts[state.ptIdx][1] += dy; commitAbsPoints(el, pts); } refreshAllUI(true); } function centerCurrentSegment() { const el = getActiveArrow(); if (!el) return; const absPts = getAbsolutePoints(el); if (absPts.length < 4 || state.segIdx <= 0 || state.segIdx >= absPts.length - 2) return; const i = state.segIdx; const orient = getSegmentOrientation(absPts[i], absPts[i + 1]); const pts = absPts.map((p) => [p[0], p[1]]); if (orient === "H") { const midY = (absPts[i - 1][1] + absPts[i + 2][1]) / 2; pts[i][1] = midY; pts[i + 1][1] = midY; } else if (orient === "V") { const midX = (absPts[i - 1][0] + absPts[i + 2][0]) / 2; pts[i][0] = midX; pts[i + 1][0] = midX; } else { const midX = (absPts[i - 1][0] + absPts[i + 2][0]) / 2; const midY = (absPts[i - 1][1] + absPts[i + 2][1]) / 2; const curMidX = (absPts[i][0] + absPts[i + 1][0]) / 2; const curMidY = (absPts[i][1] + absPts[i + 1][1]) / 2; const dx = midX - curMidX; const dy = midY - curMidY; pts[i][0] += dx; pts[i][1] += dy; pts[i + 1][0] += dx; pts[i + 1][1] += dy; } commitAbsPoints(el, pts); refreshAllUI(true); } function orthogonalizeCurrentSegment(targetOrient) { const el = getActiveArrow(); if (!el) return; const absPts = getAbsolutePoints(el); const i = state.segIdx; if (i < 0 || i >= absPts.length - 1) return; const pts = absPts.map((p) => [p[0], p[1]]); if (targetOrient === "H") { const y = (pts[i][1] + pts[i + 1][1]) / 2; pts[i][1] = y; pts[i + 1][1] = y; } else { const x = (pts[i][0] + pts[i + 1][0]) / 2; pts[i][0] = x; pts[i + 1][0] = x; } commitAbsPoints(el, pts); refreshAllUI(true); } function alignPointToNeighbor(which, axis) { const el = getActiveArrow(); if (!el) return; const absPts = getAbsolutePoints(el); const refIdx = which === "prev" ? state.ptIdx - 1 : state.ptIdx + 1; if (refIdx < 0 || refIdx >= absPts.length) return; const pts = absPts.map((p) => [p[0], p[1]]); if (axis === "x") pts[state.ptIdx][0] = pts[refIdx][0]; else pts[state.ptIdx][1] = pts[refIdx][1]; commitAbsPoints(el, pts); refreshAllUI(true); } function addDetourJog() { const el = getActiveArrow(); if (!el) return new Notice(t("notice_no_arrow")); const absPts = getAbsolutePoints(el); const i = state.segIdx; if (i < 0 || i >= absPts.length - 1) return; const p1 = absPts[i]; const p2 = absPts[i + 1]; const orient = getSegmentOrientation(p1, p2); const offset = Math.max(10, state.step * 2); let a, b, c, d; if (orient === "V") { const y1 = p1[1] + (p2[1] - p1[1]) * 0.35; const y2 = p1[1] + (p2[1] - p1[1]) * 0.65; a = [p1[0], y1]; b = [p1[0] + offset, y1]; c = [p1[0] + offset, y2]; d = [p1[0], y2]; } else { const x1 = p1[0] + (p2[0] - p1[0]) * 0.35; const x2 = p1[0] + (p2[0] - p1[0]) * 0.65; const yMid = (p1[1] + p2[1]) / 2; a = [x1, p1[1]]; b = [x1, yMid - offset]; c = [x2, yMid - offset]; d = [x2, p2[1]]; } const pts = [ ...absPts.slice(0, i + 1), a, b, c, d, ...absPts.slice(i + 1) ]; state.mode = "edge"; state.segIdx = i + 2; commitAbsPoints(el, pts); new Notice(t("notice_jog_added")); refreshAllUI(true); } function splitSegmentMidpoint() { const el = getActiveArrow(); if (!el) return new Notice(t("notice_no_arrow")); const absPts = getAbsolutePoints(el); const i = state.segIdx; if (i < 0 || i >= absPts.length - 1) return; const mid = [ (absPts[i][0] + absPts[i + 1][0]) / 2, (absPts[i][1] + absPts[i + 1][1]) / 2 ]; const pts = [...absPts.slice(0, i + 1), mid, ...absPts.slice(i + 1)]; state.mode = "point"; state.ptIdx = i + 1; commitAbsPoints(el, pts); new Notice(t("notice_split_added")); refreshAllUI(true); } function deleteCurrentPoint() { const el = getActiveArrow(); if (!el) return new Notice(t("notice_no_arrow")); const absPts = getAbsolutePoints(el); if (absPts.length <= 2) { new Notice(t("notice_need_2pts")); return; } const idx = state.mode === "point" ? state.ptIdx : clamp(state.segIdx + 1, 0, absPts.length - 1); const pts = absPts.filter((_, k) => k !== idx); clampIndices(pts.length); commitAbsPoints(el, pts); new Notice(t("notice_pt_deleted")); refreshAllUI(true); } function simplifyCollinearPoints() { const el = getActiveArrow(); if (!el) return new Notice(t("notice_no_arrow")); const absPts = getAbsolutePoints(el); if (absPts.length <= 2) { new Notice(t("notice_no_redundant")); return; } const dedup = [absPts[0]]; for (let i = 1; i < absPts.length; i++) { const prev = dedup[dedup.length - 1]; if (Math.hypot(absPts[i][0] - prev[0], absPts[i][1] - prev[1]) > 0.5 || i === absPts.length - 1) { dedup.push(absPts[i]); } } const cleaned = [dedup[0]]; for (let i = 1; i < dedup.length - 1; i++) { const a = cleaned[cleaned.length - 1]; const b = dedup[i]; const c = dedup[i + 1]; const v1x = b[0] - a[0], v1y = b[1] - a[1]; const v2x = c[0] - b[0], v2y = c[1] - b[1]; const l1 = Math.hypot(v1x, v1y), l2 = Math.hypot(v2x, v2y); const cross = Math.abs(v1x * v2y - v1y * v2x); const dot = v1x * v2x + v1y * v2y; const acLen = Math.hypot(c[0] - a[0], c[1] - a[1]); const distLine = acLen > 0.5 ? cross / acLen : 0; if (l1 > 0.5 && l2 > 0.5 && distLine < 0.5 && dot > 0) { continue; } cleaned.push(b); } cleaned.push(dedup[dedup.length - 1]); const removed = absPts.length - cleaned.length; if (removed <= 0) { new Notice(t("notice_no_redundant")); return; } clampIndices(cleaned.length); commitAbsPoints(el, cleaned); new Notice(t("notice_simplified", { count: removed })); refreshAllUI(true); } function convertToFreePolyline() { const el = getActiveArrow(); if (!el) return; const absPts = getAbsolutePoints(el); commitAbsPoints(el, absPts, { unelbow: true }); new Notice(t("notice_unelbowed")); refreshAllUI(true); } // ========================================== // Inject Styles // ========================================== const oldStyle = doc.getElementById(STYLE_ID); if (oldStyle) oldStyle.remove(); const styleEl = doc.createElement("style"); styleEl.id = STYLE_ID; styleEl.textContent = ` #${OVERLAY_ID} { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 30; overflow: visible; } #${OVERLAY_ID} .ymjr-ov-hit { pointer-events: auto; cursor: pointer; } #${OVERLAY_ID} .ymjr-ov-handle-h { cursor: ns-resize; } #${OVERLAY_ID} .ymjr-ov-handle-v { cursor: ew-resize; } #${OVERLAY_ID} .ymjr-ov-handle-d, #${OVERLAY_ID} .ymjr-ov-pt { cursor: move; } #${PANEL_ID} { position: absolute; top: 16px; right: 16px; width: 312px; max-height: calc(100% - 32px); overflow-y: auto; background: var(--background-primary); border: 1px solid var(--background-modifier-border); border-radius: 10px; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24); z-index: 120; font-family: var(--font-interface); font-size: 12px; color: var(--text-normal); user-select: none; display: flex; flex-direction: column; } #${PANEL_ID} * { box-sizing: border-box; } #${PANEL_ID} .ymjr-hdr { display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; background: var(--background-secondary); border-bottom: 1px solid var(--background-modifier-border); cursor: grab; border-radius: 10px 10px 0 0; gap: 6px; } #${PANEL_ID} .ymjr-hdr:active { cursor: grabbing; } #${PANEL_ID} .ymjr-hdr-title { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #${PANEL_ID} .ymjr-hdr-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } #${PANEL_ID} .ymjr-icon-btn { background: transparent; border: 1px solid transparent; border-radius: 5px; color: var(--text-muted); cursor: pointer; padding: 3px 6px; font-size: 12px; line-height: 1.2; display: inline-flex; align-items: center; justify-content: center; } #${PANEL_ID} .ymjr-icon-btn:hover { background: var(--background-modifier-hover); color: var(--text-normal); } #${PANEL_ID} .ymjr-icon-btn.is-active { background: var(--interactive-accent); color: var(--text-on-accent); } #${PANEL_ID} .ymjr-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; } #${PANEL_ID} .ymjr-body { padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 10px; } #${PANEL_ID} .ymjr-multi-bar { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 11px; color: var(--text-muted); } #${PANEL_ID} .ymjr-chip { padding: 2px 7px; border-radius: 999px; border: 1px solid var(--background-modifier-border); background: var(--background-secondary); color: var(--text-normal); cursor: pointer; font-size: 11px; } #${PANEL_ID} .ymjr-chip.is-active { background: var(--interactive-accent); color: var(--text-on-accent); border-color: var(--interactive-accent); } #${PANEL_ID} .ymjr-preview-wrap { position: relative; width: 100%; height: 128px; background: var(--background-primary-alt); border: 1px solid var(--background-modifier-border); border-radius: 7px; overflow: hidden; } #${PANEL_ID} .ymjr-preview-meta { position: absolute; top: 5px; left: 7px; right: 7px; display: flex; justify-content: space-between; align-items: center; pointer-events: none; font-size: 10px; color: var(--text-muted); } #${PANEL_ID} .ymjr-badge { background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; padding: 1px 5px; } #${PANEL_ID} .ymjr-seg-tabs { display: grid; grid-template-columns: 1fr 1fr; background: var(--background-secondary); padding: 2px; border-radius: 6px; border: 1px solid var(--background-modifier-border); gap: 2px; } #${PANEL_ID} .ymjr-seg-tab { border: none; background: transparent; color: var(--text-muted); padding: 5px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500; } #${PANEL_ID} .ymjr-seg-tab.is-active { background: var(--background-primary); color: var(--text-normal); box-shadow: 0 1px 3px rgba(0,0,0,0.14); } #${PANEL_ID} .ymjr-nav-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; background: var(--background-secondary); padding: 5px 8px; border-radius: 6px; border: 1px solid var(--background-modifier-border); } #${PANEL_ID} .ymjr-nav-info { font-size: 11.5px; font-weight: 500; text-align: center; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #${PANEL_ID} .ymjr-btn { background: var(--interactive-normal); border: 1px solid var(--background-modifier-border); color: var(--text-normal); border-radius: 5px; padding: 4px 8px; font-size: 11.5px; cursor: pointer; transition: background 0.12s, border-color 0.12s; display: inline-flex; align-items: center; justify-content: center; gap: 4px; } #${PANEL_ID} .ymjr-btn:hover:not(:disabled) { background: var(--interactive-hover); border-color: var(--interactive-accent); } #${PANEL_ID} .ymjr-btn:disabled { opacity: 0.45; cursor: not-allowed; } #${PANEL_ID} .ymjr-dpad-section { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: center; } #${PANEL_ID} .ymjr-dpad { display: grid; grid-template-columns: repeat(3, 38px); grid-template-rows: repeat(3, 32px); gap: 4px; justify-content: center; } #${PANEL_ID} .ymjr-dpad button { border-radius: 6px; border: 1px solid var(--background-modifier-border); background: var(--interactive-normal); color: var(--text-normal); cursor: pointer; font-size: 14px; font-weight: 600; display: flex; align-items: center; justify-content: center; padding: 0; } #${PANEL_ID} .ymjr-dpad button:hover:not(:disabled) { background: var(--interactive-hover); border-color: var(--interactive-accent); } #${PANEL_ID} .ymjr-dpad button:disabled { opacity: 0.4; cursor: not-allowed; } #${PANEL_ID} .ymjr-dpad button.is-suggested { border-color: var(--interactive-accent); background: rgba(var(--interactive-accent-rgb, 100, 120, 255), 0.14); color: var(--text-accent); } #${PANEL_ID} .ymjr-dpad button.ymjr-dpad-center { font-size: 10.5px; font-weight: 500; } #${PANEL_ID} .ymjr-step-col { display: flex; flex-direction: column; gap: 6px; } #${PANEL_ID} .ymjr-step-presets { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3px; } #${PANEL_ID} .ymjr-step-presets button { padding: 3px 0; font-size: 11px; border-radius: 4px; border: 1px solid var(--background-modifier-border); background: var(--background-secondary); color: var(--text-normal); cursor: pointer; } #${PANEL_ID} .ymjr-step-presets button.is-active { background: var(--interactive-accent); color: var(--text-on-accent); border-color: var(--interactive-accent); } #${PANEL_ID} .ymjr-input-row { display: flex; align-items: center; gap: 5px; } #${PANEL_ID} input[type="number"] { width: 100%; min-width: 0; background: var(--background-modifier-form-field); border: 1px solid var(--background-modifier-border); color: var(--text-normal); border-radius: 5px; padding: 4px 6px; font-size: 12px; outline: none; } #${PANEL_ID} input[type="number"]:focus { border-color: var(--interactive-accent); } #${PANEL_ID} .ymjr-coord-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } #${PANEL_ID} .ymjr-field { display: flex; flex-direction: column; gap: 3px; } #${PANEL_ID} .ymjr-field label { font-size: 10.5px; color: var(--text-muted); } #${PANEL_ID} .ymjr-tools-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 5px; } #${PANEL_ID} .ymjr-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 11.5px; color: var(--text-muted); } #${PANEL_ID} .ymjr-toggle-row label { display: flex; align-items: center; gap: 6px; cursor: pointer; color: var(--text-normal); } #${PANEL_ID} .ymjr-empty { padding: 24px 12px; text-align: center; color: var(--text-muted); line-height: 1.5; white-space: pre-line; font-size: 12px; } `; doc.head.appendChild(styleEl); // ========================================== // Build Overlay SVG + Panel DOM // ========================================== const oldOverlay = hostEl.querySelector(`#${OVERLAY_ID}`); if (oldOverlay) oldOverlay.remove(); const oldPanel = hostEl.querySelector(`#${PANEL_ID}`); if (oldPanel) oldPanel.remove(); const svgNS = "http://www.w3.org/2000/svg"; const overlaySvg = doc.createElementNS(svgNS, "svg"); overlaySvg.id = OVERLAY_ID; hostEl.appendChild(overlaySvg); const panel = doc.createElement("div"); panel.id = PANEL_ID; panel.innerHTML = `