--- 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 = `
${t("panel_title")}
${t("empty_hint")}
`; hostEl.appendChild(panel); panel.addEventListener("keydown", (e) => { if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) { e.stopPropagation(); } }); panel.addEventListener("pointerdown", (e) => e.stopPropagation()); panel.addEventListener("mousedown", (e) => e.stopPropagation()); // ========================================== // Panel Dragging // ========================================== const hdrEl = panel.querySelector(".ymjr-hdr"); let panelDrag = null; const onPanelMove = (e) => { if (!panelDrag) return; e.preventDefault(); const hostRect = hostEl.getBoundingClientRect(); let nx = panelDrag.initLeft + (e.clientX - panelDrag.startX); let ny = panelDrag.initTop + (e.clientY - panelDrag.startY); nx = clamp(nx, 4, Math.max(4, hostRect.width - panel.offsetWidth - 4)); ny = clamp(ny, 4, Math.max(4, hostRect.height - 44)); panel.style.left = `${nx}px`; panel.style.top = `${ny}px`; panel.style.right = "auto"; }; const onPanelUp = () => { panelDrag = null; }; hdrEl.addEventListener("mousedown", (e) => { if (e.target.closest("button")) return; const rect = panel.getBoundingClientRect(); const hostRect = hostEl.getBoundingClientRect(); panelDrag = { startX: e.clientX, startY: e.clientY, initLeft: rect.left - hostRect.left, initTop: rect.top - hostRect.top }; e.preventDefault(); }); // ========================================== // UI Rendering (Panel Preview + Controls) // ========================================== const multiBarEl = panel.querySelector("#ymjr-multi-bar"); const emptyEl = panel.querySelector("#ymjr-empty-state"); const editorBodyEl = panel.querySelector("#ymjr-editor-body"); const previewSvg = panel.querySelector("#ymjr-preview-svg"); const metaPtsEl = panel.querySelector("#ymjr-meta-pts"); const metaTypeEl = panel.querySelector("#ymjr-meta-type"); const navInfoEl = panel.querySelector("#ymjr-nav-info"); const coordBoxEl = panel.querySelector("#ymjr-coord-box"); const toolsGridEl = panel.querySelector("#ymjr-tools-grid"); const stepInputEl = panel.querySelector("#ymjr-step-input"); const protectChkEl = panel.querySelector("#ymjr-chk-protect"); const btnHandlesEl = panel.querySelector("#ymjr-btn-handles"); const btnKbdEl = panel.querySelector("#ymjr-btn-kbd"); const btnUndoEl = panel.querySelector("#ymjr-btn-undo"); const btnUp = panel.querySelector("#ymjr-nudge-up"); const btnDown = panel.querySelector("#ymjr-nudge-down"); const btnLeft = panel.querySelector("#ymjr-nudge-left"); const btnRight = panel.querySelector("#ymjr-nudge-right"); const btnCenter = panel.querySelector("#ymjr-nudge-center"); function renderPreviewSVG(el, absPts) { while (previewSvg.firstChild) previewSvg.removeChild(previewSvg.firstChild); const W = 286, H = 126, padTop = 26, pad = 18; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const [x, y] of absPts) { if (x < minX) minX = x; if (y < minY) minY = y; if (x > maxX) maxX = x; if (y > maxY) maxY = y; } const spanX = Math.max(1, maxX - minX); const spanY = Math.max(1, maxY - minY); const availW = W - pad * 2; const availH = H - padTop - pad; const scale = Math.min(availW / spanX, availH / spanY); const offX = pad + (availW - spanX * scale) / 2 - minX * scale; const offY = padTop + (availH - spanY * scale) / 2 - minY * scale; const toSvg = ([x, y]) => [x * scale + offX, y * scale + offY]; const svgPts = absPts.map(toSvg); for (let i = 0; i < svgPts.length - 1; i++) { const [x1, y1] = svgPts[i]; const [x2, y2] = svgPts[i + 1]; const isSelectedSeg = state.mode === "edge" && state.segIdx === i; const line = doc.createElementNS(svgNS, "line"); line.setAttribute("x1", x1); line.setAttribute("y1", y1); line.setAttribute("x2", x2); line.setAttribute("y2", y2); line.setAttribute("stroke", isSelectedSeg ? "var(--interactive-accent)" : "var(--text-muted)"); line.setAttribute("stroke-width", isSelectedSeg ? "4" : "2"); line.setAttribute("stroke-linecap", "round"); if (el.strokeStyle === "dashed") line.setAttribute("stroke-dasharray", "4 3"); previewSvg.appendChild(line); const hit = doc.createElementNS(svgNS, "line"); hit.setAttribute("x1", x1); hit.setAttribute("y1", y1); hit.setAttribute("x2", x2); hit.setAttribute("y2", y2); hit.setAttribute("stroke", "transparent"); hit.setAttribute("stroke-width", "14"); hit.style.cursor = "pointer"; hit.addEventListener("click", () => { state.mode = "edge"; state.segIdx = i; refreshAllUI(true); }); previewSvg.appendChild(hit); const mx = (x1 + x2) / 2; const my = (y1 + y2) / 2; const pillG = doc.createElementNS(svgNS, "g"); pillG.style.cursor = "pointer"; pillG.addEventListener("click", () => { state.mode = "edge"; state.segIdx = i; refreshAllUI(true); }); const pill = doc.createElementNS(svgNS, "rect"); pill.setAttribute("x", mx - 9); pill.setAttribute("y", my - 7); pill.setAttribute("width", "18"); pill.setAttribute("height", "14"); pill.setAttribute("rx", "4"); pill.setAttribute("fill", isSelectedSeg ? "var(--interactive-accent)" : "var(--background-secondary)"); pill.setAttribute("stroke", isSelectedSeg ? "var(--interactive-accent)" : "var(--background-modifier-border)"); pillG.appendChild(pill); const txt = doc.createElementNS(svgNS, "text"); txt.setAttribute("x", mx); txt.setAttribute("y", my + 3.2); txt.setAttribute("text-anchor", "middle"); txt.setAttribute("font-size", "9"); txt.setAttribute("font-weight", "600"); txt.setAttribute("fill", isSelectedSeg ? "var(--text-on-accent)" : "var(--text-normal)"); txt.textContent = `E${i + 1}`; pillG.appendChild(txt); previewSvg.appendChild(pillG); } if (svgPts.length >= 2 && el.type === "arrow") { const [px, py] = svgPts[svgPts.length - 2]; const [ex, ey] = svgPts[svgPts.length - 1]; const ang = Math.atan2(ey - py, ex - px); const sz = 7; const a1 = [ex - sz * Math.cos(ang - 0.5), ey - sz * Math.sin(ang - 0.5)]; const a2 = [ex - sz * Math.cos(ang + 0.5), ey - sz * Math.sin(ang + 0.5)]; const head = doc.createElementNS(svgNS, "polygon"); head.setAttribute("points", `${ex},${ey} ${a1[0]},${a1[1]} ${a2[0]},${a2[1]}`); head.setAttribute("fill", "var(--text-normal)"); previewSvg.appendChild(head); } for (let i = 0; i < svgPts.length; i++) { const [vx, vy] = svgPts[i]; const isSelectedPt = state.mode === "point" && state.ptIdx === i; const isEdgeEnd = state.mode === "edge" && (i === state.segIdx || i === state.segIdx + 1); const circle = doc.createElementNS(svgNS, "circle"); circle.setAttribute("cx", vx); circle.setAttribute("cy", vy); circle.setAttribute("r", isSelectedPt ? "6.5" : isEdgeEnd ? "4.5" : "3.5"); circle.setAttribute( "fill", isSelectedPt ? "var(--interactive-accent)" : isEdgeEnd ? "var(--text-accent)" : "var(--background-primary)" ); circle.setAttribute("stroke", isSelectedPt || isEdgeEnd ? "var(--interactive-accent)" : "var(--text-muted)"); circle.setAttribute("stroke-width", "1.5"); circle.style.cursor = "pointer"; circle.addEventListener("click", (e) => { e.stopPropagation(); state.mode = "point"; state.ptIdx = i; refreshAllUI(true); }); previewSvg.appendChild(circle); } } function renderPanelControls(el) { if (!el) { emptyEl.style.display = "block"; editorBodyEl.style.display = "none"; multiBarEl.style.display = "none"; btnUndoEl.disabled = state.undoStack.length === 0; return; } emptyEl.style.display = "none"; editorBodyEl.style.display = "flex"; if (state.candidateIds.length > 1) { multiBarEl.style.display = "flex"; multiBarEl.innerHTML = `${t("multi_label", { n: state.candidateIds.length })}`; state.candidateIds.forEach((cid, idx) => { const chip = doc.createElement("button"); chip.className = `ymjr-chip ${cid === el.id ? "is-active" : ""}`; chip.textContent = `#${idx + 1}`; chip.onclick = () => { state.activeId = cid; const nextEl = getActiveArrow(); if (nextEl) clampIndices(nextEl.points.length); refreshAllUI(true); }; multiBarEl.appendChild(chip); }); } else { multiBarEl.style.display = "none"; } const absPts = getAbsolutePoints(el); clampIndices(absPts.length); metaPtsEl.textContent = t("badge_points", { pts: absPts.length, segs: absPts.length - 1 }); metaTypeEl.textContent = el.elbowed ? t("badge_elbow") : t("badge_free"); panel.querySelectorAll(".ymjr-seg-tab").forEach((tab) => { tab.classList.toggle("is-active", tab.dataset.mode === state.mode); }); renderPreviewSVG(el, absPts); [btnUp, btnDown, btnLeft, btnRight].forEach((b) => b.classList.remove("is-suggested")); if (state.mode === "edge") { const p1 = absPts[state.segIdx]; const p2 = absPts[state.segIdx + 1]; const orient = getSegmentOrientation(p1, p2); const len = Math.round(Math.hypot(p2[0] - p1[0], p2[1] - p1[1])); const orientStr = orient === "H" ? t("orient_h") : orient === "V" ? t("orient_v") : orient === "D" ? t("orient_d") : t("orient_z"); navInfoEl.textContent = t("seg_info", { idx: state.segIdx + 1, orient: orientStr, len }); if (orient === "H") { btnUp.classList.add("is-suggested"); btnDown.classList.add("is-suggested"); } else if (orient === "V") { btnLeft.classList.add("is-suggested"); btnRight.classList.add("is-suggested"); } btnCenter.disabled = !(absPts.length >= 4 && state.segIdx > 0 && state.segIdx < absPts.length - 2); renderEdgeCoordBox(el, absPts, orient); } else { navInfoEl.textContent = t("pt_info", { idx: state.ptIdx, total: absPts.length - 1 }); btnCenter.disabled = true; renderPointCoordBox(el, absPts); } renderToolsGrid(el, absPts); btnUndoEl.disabled = state.undoStack.length === 0; } function renderEdgeCoordBox(el, absPts, orient) { coordBoxEl.innerHTML = ""; const i = state.segIdx; const p1 = absPts[i]; const p2 = absPts[i + 1]; if (orient === "H" || orient === "V") { const isH = orient === "H"; const val = round2(isH ? p1[1] : p1[0]); const field1 = doc.createElement("div"); field1.className = "ymjr-field"; field1.innerHTML = ``; const row1 = doc.createElement("div"); row1.className = "ymjr-input-row"; const inpPos = doc.createElement("input"); inpPos.type = "number"; inpPos.step = String(state.step || 1); inpPos.value = String(val); const applyPos = () => { const targetVal = parseFloat(inpPos.value); if (isNaN(targetVal)) return; const delta = targetVal - val; if (Math.abs(delta) < 1e-4) return; nudgeSelection(isH ? 0 : delta, isH ? delta : 0); }; inpPos.addEventListener("keydown", (e) => { if (e.key === "Enter") applyPos(); }); inpPos.addEventListener("change", applyPos); row1.appendChild(inpPos); field1.appendChild(row1); const field2 = doc.createElement("div"); field2.className = "ymjr-field"; field2.innerHTML = ``; const row2 = doc.createElement("div"); row2.className = "ymjr-input-row"; const inpLen = doc.createElement("input"); inpLen.type = "number"; inpLen.min = "0"; inpLen.step = String(state.step || 1); inpLen.value = String(round2(Math.hypot(p2[0] - p1[0], p2[1] - p1[1]))); const applyLen = () => { const targetLen = parseFloat(inpLen.value); if (isNaN(targetLen) || targetLen < 0) return; const pts = absPts.map((p) => [p[0], p[1]]); const dir = isH ? Math.sign(p2[0] - p1[0]) || 1 : Math.sign(p2[1] - p1[1]) || 1; if (i < pts.length - 2 || i === 0) { if (isH) pts[i + 1][0] = pts[i][0] + dir * targetLen; else pts[i + 1][1] = pts[i][1] + dir * targetLen; } else { if (isH) pts[i][0] = pts[i + 1][0] - dir * targetLen; else pts[i][1] = pts[i + 1][1] - dir * targetLen; } commitAbsPoints(el, pts); refreshAllUI(true); }; inpLen.addEventListener("keydown", (e) => { if (e.key === "Enter") applyLen(); }); inpLen.addEventListener("change", applyLen); row2.appendChild(inpLen); field2.appendChild(row2); coordBoxEl.appendChild(field1); coordBoxEl.appendChild(field2); } else { const bH = doc.createElement("button"); bH.className = "ymjr-btn"; bH.textContent = t("ortho_h"); bH.onclick = () => orthogonalizeCurrentSegment("H"); const bV = doc.createElement("button"); bV.className = "ymjr-btn"; bV.textContent = t("ortho_v"); bV.onclick = () => orthogonalizeCurrentSegment("V"); coordBoxEl.appendChild(bH); coordBoxEl.appendChild(bV); } } function renderPointCoordBox(el, absPts) { coordBoxEl.innerHTML = ""; const pt = absPts[state.ptIdx]; const makeAxisField = (label, axisIdx) => { const f = doc.createElement("div"); f.className = "ymjr-field"; f.innerHTML = ``; const inp = doc.createElement("input"); inp.type = "number"; inp.step = String(state.step || 1); inp.value = String(round2(pt[axisIdx])); const apply = () => { const v = parseFloat(inp.value); if (isNaN(v)) return; const pts = absPts.map((p) => [p[0], p[1]]); pts[state.ptIdx][axisIdx] = v; commitAbsPoints(el, pts); refreshAllUI(true); }; inp.addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); }); inp.addEventListener("change", apply); f.appendChild(inp); return f; }; coordBoxEl.appendChild(makeAxisField(t("pt_x"), 0)); coordBoxEl.appendChild(makeAxisField(t("pt_y"), 1)); if (state.ptIdx > 0) { const bpx = doc.createElement("button"); bpx.className = "ymjr-btn"; bpx.textContent = t("align_prev_x"); bpx.onclick = () => alignPointToNeighbor("prev", "x"); const bpy = doc.createElement("button"); bpy.className = "ymjr-btn"; bpy.textContent = t("align_prev_y"); bpy.onclick = () => alignPointToNeighbor("prev", "y"); coordBoxEl.appendChild(bpx); coordBoxEl.appendChild(bpy); } if (state.ptIdx < absPts.length - 1) { const bnx = doc.createElement("button"); bnx.className = "ymjr-btn"; bnx.textContent = t("align_next_x"); bnx.onclick = () => alignPointToNeighbor("next", "x"); const bny = doc.createElement("button"); bny.className = "ymjr-btn"; bny.textContent = t("align_next_y"); bny.onclick = () => alignPointToNeighbor("next", "y"); coordBoxEl.appendChild(bnx); coordBoxEl.appendChild(bny); } } function renderToolsGrid(el, absPts) { toolsGridEl.innerHTML = ""; const addToolBtn = (label, onClick, disabled = false) => { const b = doc.createElement("button"); b.className = "ymjr-btn"; b.textContent = label; b.disabled = disabled; b.onclick = onClick; toolsGridEl.appendChild(b); }; addToolBtn(t("tool_jog"), addDetourJog, state.mode !== "edge"); addToolBtn(t("tool_split"), splitSegmentMidpoint, state.mode !== "edge"); addToolBtn(t("tool_del_pt"), deleteCurrentPoint, absPts.length <= 2); addToolBtn(t("tool_simplify"), simplifyCollinearPoints, absPts.length <= 2); if (el.elbowed) { addToolBtn(t("tool_unelbow"), convertToFreePolyline, false); } } // ========================================== // On-Canvas Interactive SVG Overlay // ========================================== function renderCanvasOverlay(el) { while (overlaySvg.firstChild) overlaySvg.removeChild(overlaySvg.firstChild); if (!el || !state.showHandles) return; const appState = api.getAppState(); const zoom = appState?.zoom?.value || 1; const hostRect = hostEl.getBoundingClientRect(); const offLeft = (appState.offsetLeft || 0) - hostRect.left; const offTop = (appState.offsetTop || 0) - hostRect.top; const toHost = ([sx, sy]) => [ (sx + appState.scrollX) * zoom + offLeft, (sy + appState.scrollY) * zoom + offTop ]; const absPts = getAbsolutePoints(el); clampIndices(absPts.length); const screenPts = absPts.map(toHost); if (state.mode === "edge") { const [x1, y1] = screenPts[state.segIdx]; const [x2, y2] = screenPts[state.segIdx + 1]; const glow = doc.createElementNS(svgNS, "line"); glow.setAttribute("x1", x1); glow.setAttribute("y1", y1); glow.setAttribute("x2", x2); glow.setAttribute("y2", y2); glow.setAttribute("stroke", "var(--interactive-accent)"); glow.setAttribute("stroke-width", "6"); glow.setAttribute("stroke-opacity", "0.45"); glow.setAttribute("stroke-linecap", "round"); overlaySvg.appendChild(glow); } for (let i = 0; i < screenPts.length - 1; i++) { const [x1, y1] = screenPts[i]; const [x2, y2] = screenPts[i + 1]; const segScreenLen = Math.hypot(x2 - x1, y2 - y1); if (segScreenLen < 14) continue; const mx = (x1 + x2) / 2; const my = (y1 + y2) / 2; const orient = getSegmentOrientation(absPts[i], absPts[i + 1]); const isSelected = state.mode === "edge" && state.segIdx === i; const g = doc.createElementNS(svgNS, "g"); const cursorCls = orient === "H" ? "ymjr-ov-handle-h" : orient === "V" ? "ymjr-ov-handle-v" : "ymjr-ov-handle-d"; g.setAttribute("class", `ymjr-ov-hit ${cursorCls}`); const rect = doc.createElementNS(svgNS, "rect"); rect.setAttribute("x", mx - 11); rect.setAttribute("y", my - 9); rect.setAttribute("width", "22"); rect.setAttribute("height", "18"); rect.setAttribute("rx", "9"); rect.setAttribute("fill", isSelected ? "var(--interactive-accent)" : "var(--background-primary)"); rect.setAttribute("stroke", "var(--interactive-accent)"); rect.setAttribute("stroke-width", isSelected ? "2" : "1.5"); g.appendChild(rect); const label = doc.createElementNS(svgNS, "text"); label.setAttribute("x", mx); label.setAttribute("y", my + 3.5); label.setAttribute("text-anchor", "middle"); label.setAttribute("font-size", "10"); label.setAttribute("font-weight", "700"); label.setAttribute("fill", isSelected ? "var(--text-on-accent)" : "var(--text-normal)"); label.textContent = String(i + 1); g.appendChild(label); g.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); startCanvasDrag("edge", i, orient, e); }); overlaySvg.appendChild(g); } for (let i = 0; i < screenPts.length; i++) { const [vx, vy] = screenPts[i]; const isSelected = state.mode === "point" && state.ptIdx === i; const c = doc.createElementNS(svgNS, "circle"); c.setAttribute("class", "ymjr-ov-hit ymjr-ov-pt"); c.setAttribute("cx", vx); c.setAttribute("cy", vy); c.setAttribute("r", isSelected ? "6.5" : "4.5"); c.setAttribute("fill", isSelected ? "var(--interactive-accent)" : "var(--background-primary)"); c.setAttribute("stroke", "var(--interactive-accent)"); c.setAttribute("stroke-width", "1.8"); c.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); startCanvasDrag("point", i, "D", e); }); overlaySvg.appendChild(c); } if (state.dragState && state.dragState.hudText) { const hud = doc.createElementNS(svgNS, "g"); const hx = state.dragState.lastClientX - hostRect.left + 14; const hy = state.dragState.lastClientY - hostRect.top - 12; const bg = doc.createElementNS(svgNS, "rect"); bg.setAttribute("x", hx); bg.setAttribute("y", hy - 14); bg.setAttribute("width", Math.max(66, state.dragState.hudText.length * 7.2 + 12)); bg.setAttribute("height", "20"); bg.setAttribute("rx", "5"); bg.setAttribute("fill", "var(--background-secondary)"); bg.setAttribute("stroke", "var(--interactive-accent)"); hud.appendChild(bg); const ht = doc.createElementNS(svgNS, "text"); ht.setAttribute("x", hx + 6); ht.setAttribute("y", hy); ht.setAttribute("font-size", "11"); ht.setAttribute("font-weight", "600"); ht.setAttribute("fill", "var(--text-normal)"); ht.textContent = state.dragState.hudText; hud.appendChild(ht); overlaySvg.appendChild(hud); } } // ========================================== // On-Canvas Direct Dragging // ========================================== function startCanvasDrag(type, idx, orient, e) { const el = getActiveArrow(); if (!el) return; const origAbsPts = getAbsolutePoints(el); state.mode = type; if (type === "edge") state.segIdx = idx; else state.ptIdx = idx; const sceneEls = api.getSceneElements() || []; const boundText = findBoundText(el, sceneEls); state.dragState = { type, idx, orient, startClientX: e.clientX, startClientY: e.clientY, lastClientX: e.clientX, lastClientY: e.clientY, origEl: JSON.parse(JSON.stringify(el)), origAbsPts, origBoundText: boundText ? JSON.parse(JSON.stringify(boundText)) : null, moved: false, lastComputedPts: null, hudText: "" }; refreshAllUI(true); } function onCanvasDragMove(e) { const ds = state.dragState; if (!ds) return; e.preventDefault(); const screenDist = Math.hypot(e.clientX - ds.startClientX, e.clientY - ds.startClientY); if (!ds.moved && screenDist < 2) return; ds.moved = true; const appState = api.getAppState(); const zoom = appState?.zoom?.value || 1; let dx = (e.clientX - ds.startClientX) / zoom; let dy = (e.clientY - ds.startClientY) / zoom; ds.lastClientX = e.clientX; ds.lastClientY = e.clientY; if (ds.type === "edge" && !e.shiftKey) { if (ds.orient === "H") dx = 0; else if (ds.orient === "V") dy = 0; } const snapUnit = e.altKey ? 1 : state.snapToStep ? Math.max(1, state.step) : 1; if (snapUnit > 1) { dx = Math.round(dx / snapUnit) * snapUnit; dy = Math.round(dy / snapUnit) * snapUnit; } else { dx = round2(dx); dy = round2(dy); } const currentEl = getActiveArrow() || ds.origEl; if (ds.type === "edge") { const { pts, nextSegIdx } = computeMovedSegmentAbsPoints( ds.origAbsPts, ds.idx, dx, dy, state.protectEndpoints ); state.segIdx = nextSegIdx; ds.lastComputedPts = pts; ds.hudText = dx !== 0 && dy !== 0 ? `Δx:${dx} Δy:${dy}` : dx !== 0 ? `Δx: ${dx}px` : `Δy: ${dy}px`; commitAbsPoints(currentEl, pts, { captureUpdate: "EVENTUALLY", pushUndo: false }); } else { const pts = ds.origAbsPts.map((p) => [p[0], p[1]]); let nx = pts[ds.idx][0] + dx; let ny = pts[ds.idx][1] + dy; if (e.shiftKey) { const ref = ds.idx > 0 ? pts[ds.idx - 1] : pts[ds.idx + 1]; if (ref) { if (Math.abs(nx - ref[0]) < 12) nx = ref[0]; if (Math.abs(ny - ref[1]) < 12) ny = ref[1]; } } pts[ds.idx][0] = nx; pts[ds.idx][1] = ny; ds.lastComputedPts = pts; ds.hudText = `(${Math.round(nx)}, ${Math.round(ny)})`; commitAbsPoints(currentEl, pts, { captureUpdate: "EVENTUALLY", pushUndo: false }); } refreshAllUI(true); } function onCanvasDragEnd() { const ds = state.dragState; if (!ds) return; state.dragState = null; if (ds.moved && ds.lastComputedPts) { state.undoStack.push({ arrowId: ds.origEl.id, arrowSnapshot: ds.origEl, boundTextSnapshot: ds.origBoundText }); if (state.undoStack.length > 40) state.undoStack.shift(); const currentEl = getActiveArrow() || ds.origEl; commitAbsPoints(currentEl, ds.lastComputedPts, { captureUpdate: "IMMEDIATELY", pushUndo: false }); } refreshAllUI(true); } // ========================================== // Panel Button Bindings // ========================================== panel.querySelectorAll(".ymjr-seg-tab").forEach((tab) => { tab.onclick = () => { state.mode = tab.dataset.mode; refreshAllUI(true); }; }); panel.querySelector("#ymjr-prev-item").onclick = () => { const el = getActiveArrow(); if (!el) return; const count = state.mode === "edge" ? el.points.length - 1 : el.points.length; if (count <= 0) return; if (state.mode === "edge") state.segIdx = (state.segIdx - 1 + count) % count; else state.ptIdx = (state.ptIdx - 1 + count) % count; refreshAllUI(true); }; panel.querySelector("#ymjr-next-item").onclick = () => { const el = getActiveArrow(); if (!el) return; const count = state.mode === "edge" ? el.points.length - 1 : el.points.length; if (count <= 0) return; if (state.mode === "edge") state.segIdx = (state.segIdx + 1) % count; else state.ptIdx = (state.ptIdx + 1) % count; refreshAllUI(true); }; const handleDpadClick = (dirX, dirY, e) => { const step = e.shiftKey ? 1 : state.step; nudgeSelection(dirX * step, dirY * step); }; btnUp.onclick = (e) => handleDpadClick(0, -1, e); btnDown.onclick = (e) => handleDpadClick(0, 1, e); btnLeft.onclick = (e) => handleDpadClick(-1, 0, e); btnRight.onclick = (e) => handleDpadClick(1, 0, e); btnCenter.onclick = centerCurrentSegment; const syncStepUI = (val) => { state.step = Math.max(0.5, Number(val) || 10); stepInputEl.value = String(state.step); panel.querySelectorAll(".ymjr-step-presets button").forEach((b) => { b.classList.toggle("is-active", Number(b.dataset.step) === state.step); }); }; panel.querySelectorAll(".ymjr-step-presets button").forEach((b) => { b.onclick = () => syncStepUI(b.dataset.step); }); stepInputEl.addEventListener("change", () => syncStepUI(stepInputEl.value)); protectChkEl.addEventListener("change", () => { state.protectEndpoints = protectChkEl.checked; }); btnHandlesEl.onclick = () => { state.showHandles = !state.showHandles; btnHandlesEl.classList.toggle("is-active", state.showHandles); refreshAllUI(true); }; btnKbdEl.onclick = () => { state.captureArrows = !state.captureArrows; btnKbdEl.classList.toggle("is-active", state.captureArrows); }; btnUndoEl.onclick = performUndo; // ========================================== // Keyboard Shortcuts // ========================================== const onDocKeyDown = (e) => { if (!panel.isConnected || !hostEl.isConnected) return; const active = doc.activeElement; if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.isContentEditable)) { return; } const appState = api.getAppState(); if (appState?.editingElement?.type === "text") return; const el = getActiveArrow(); if (!el) return; const isArrowKey = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key); if (isArrowKey && (e.altKey || state.captureArrows)) { e.preventDefault(); e.stopPropagation(); const s = e.shiftKey ? 1 : state.step; if (e.key === "ArrowUp") nudgeSelection(0, -s); else if (e.key === "ArrowDown") nudgeSelection(0, s); else if (e.key === "ArrowLeft") nudgeSelection(-s, 0); else if (e.key === "ArrowRight") nudgeSelection(s, 0); return; } if (state.captureArrows && (e.key === "[" || e.key === "]")) { e.preventDefault(); e.stopPropagation(); const count = state.mode === "edge" ? el.points.length - 1 : el.points.length; if (count > 0) { const delta = e.key === "[" ? -1 : 1; if (state.mode === "edge") state.segIdx = (state.segIdx + delta + count) % count; else state.ptIdx = (state.ptIdx + delta + count) % count; refreshAllUI(true); } } }; // ========================================== // Selection Sync & Animation Loop // ========================================== function syncSelectionFromCanvas() { if (state.dragState) return; const appState = api.getAppState(); const selMap = appState?.selectedElementIds || {}; const sceneEls = api.getSceneElements() || []; const selectedArrows = sceneEls.filter( (e) => !e.isDeleted && selMap[e.id] && (e.type === "arrow" || e.type === "line") && e.points?.length >= 2 ); state.candidateIds = selectedArrows.map((e) => e.id); if (selectedArrows.length === 0) { state.activeId = null; } else if (!selectedArrows.some((e) => e.id === state.activeId)) { state.activeId = selectedArrows[0].id; clampIndices(selectedArrows[0].points.length); } } function refreshAllUI(forcePanel = false) { syncSelectionFromCanvas(); const el = getActiveArrow(); const appState = api.getAppState(); const panelSig = el ? `${el.id}:${el.versionNonce}:${state.mode}:${state.segIdx}:${state.ptIdx}:${state.candidateIds.join(",")}:${state.undoStack.length}` : `none:${state.undoStack.length}`; if (forcePanel || panelSig !== state.lastRenderSig) { state.lastRenderSig = panelSig; renderPanelControls(el); } const hostRect = hostEl.getBoundingClientRect(); const overlaySig = el && state.showHandles ? `${el.id}:${el.versionNonce}:${state.mode}:${state.segIdx}:${state.ptIdx}:${appState.scrollX}:${appState.scrollY}:${appState.zoom?.value}:${appState.offsetLeft}:${appState.offsetTop}:${hostRect.left}:${hostRect.top}:${state.dragState?.hudText || ""}` : `hidden:${state.showHandles}`; if (forcePanel || overlaySig !== state.lastOverlaySig) { state.lastOverlaySig = overlaySig; renderCanvasOverlay(el); } } let rafId = null; let destroyed = false; const tick = () => { if (destroyed || !panel.isConnected || !hostEl.isConnected) { destroy(); return; } refreshAllUI(false); rafId = win.requestAnimationFrame(tick); }; function destroy() { if (destroyed) return; destroyed = true; if (rafId !== null) win.cancelAnimationFrame(rafId); doc.removeEventListener("mousemove", onPanelMove); doc.removeEventListener("mouseup", onPanelUp); doc.removeEventListener("pointermove", onCanvasDragMove); doc.removeEventListener("pointerup", onCanvasDragEnd); doc.removeEventListener("pointercancel", onCanvasDragEnd); doc.removeEventListener("keydown", onDocKeyDown, true); if (panel.isConnected) panel.remove(); if (overlaySvg.isConnected) overlaySvg.remove(); if (styleEl.isConnected) styleEl.remove(); viewRegistry.delete(view); } doc.addEventListener("mousemove", onPanelMove); doc.addEventListener("mouseup", onPanelUp); doc.addEventListener("pointermove", onCanvasDragMove); doc.addEventListener("pointerup", onCanvasDragEnd); doc.addEventListener("pointercancel", onCanvasDragEnd); doc.addEventListener("keydown", onDocKeyDown, true); panel.querySelector("#ymjr-btn-close").onclick = () => { destroy(); new Notice(t("notice_closed")); }; viewRegistry.set(view, { destroy }); refreshAllUI(true); rafId = win.requestAnimationFrame(tick); new Notice(t("notice_opened")); })();