--- name: 脑图核心引擎 description: 后台引擎:提供脑图折叠、排版、快捷键创建节点及实时高亮渲染。 author: ymjr version: 1.1.2 license: MIT usage: 后台常驻引擎,为带有 customData.mindmap 的元素提供折叠展开的右上角图标绘制,实现回车和 Tab 键的快捷节点生成与连线,实现方向键导航节点,以及监听节点的增删实现自动排版。 features: - 拦截 handleCanvasPointerDown/Up 实现点击展开/折叠按钮 - 拦截 onKeyDown 实现键盘方向键漫游脑图、Tab/Enter 快速新建节点、/键 折叠展开、空格键开始编辑 - 拦截 _renderInteractiveScene 在脑图根节点与父节点上绘制圆圈数字图标 - 提供 window.MindmapAPI 供脑图动作脚本调用进行连线刷新与布局重排 dependencies: - 被所有脑图相关的 Action 脚本依赖 autorun: true --- /* ```javascript */ var locales = { zh: { setting_desc: "⚠️ 请不要在此直接修改 JSON 代码。请运行 'Change Mindmap Style' 样式脚本,通过可视化 UI 面板修改脑图全局默认设置。", log_parse_fail: "解析脑图配置失败,将使用默认回退配置", notice_success: "✨ 解析生成完成,正在自动排版...", notice_no_engine: "⚠️ 脑图核心引擎未运行,无法自动排版,请手动开启 Engine 脚本!", log_unmounted: "[{id}] 🔌 已卸载", log_mounted: "[{id}] 🚀 挂载完毕", tb_child: "子节点", tb_sibling: "同级", tb_fold: "折叠", tb_unfold: "展开", tb_edit: "编辑" }, en: { setting_desc: "⚠️ Do not modify JSON directly. Use 'Change Mindmap Style' script UI to update global defaults.", log_parse_fail: "Failed to parse mindmap configuration, using fallback defaults.", notice_success: "✨ Parse complete, re-layout in progress...", notice_no_engine: "⚠️ Mindmap engine not running, layout skipped. Enable Engine script!", log_unmounted: "[{id}] 🔌 Unmounted", log_mounted: "[{id}] 🚀 Mounted successfully", tb_child: "Child", tb_sibling: "Sibling", tb_fold: "Fold", tb_unfold: "Unfold", tb_edit: "Edit" } }; locales["zh-cn"] = locales.zh; locales["zh-tw"] = locales.zh; const SCRIPT_ID = "ymjr.feature.mindmap-engine"; const DEFAULT_LEVELS = [ { level: 1, strokeColor: "black", textColor: "#ffffff", fontSize: 24, strokeWidth: 2 }, { level: 2, strokeColor: "black", textColor: "black", fontSize: 20, strokeWidth: 1 }, { level: 3, strokeColor: "black", textColor: "black", fontSize: 16, strokeWidth: 1 }, { level: 4, strokeColor: "black", textColor: "black", fontSize: 14, strokeWidth: 1 } ]; const FALLBACK_THEMES = { "rainbow": { name: "🌈 彩虹分枝", rootBg: "#343a40", colors: ["#e03131", "#1971c2", "#099268", "#e8590c", "#9c36b5", "#fcc419", "#3bc9db"], levels: DEFAULT_LEVELS }, "morandi": { name: "🎨 莫兰迪色", rootBg: "#495057", colors: ["#5c7a82", "#b38269", "#788560", "#a68894", "#7a6c5d"], levels: DEFAULT_LEVELS }, "business": { name: "💼 商务蓝调", rootBg: "#0b2545", colors: ["#1864ab", "#0b7285", "#087f5b", "#5f3dc4", "#c92a2a"], levels: DEFAULT_LEVELS }, "custom": { name: "⚙️ 自定义色", rootBg: "#1e1e1e", colors: ["#e03131", "#1971c2", "#099268", "#e8590c", "#9c36b5", "#fcc419"], levels: DEFAULT_LEVELS } }; const FALLBACK_SETTINGS = { direction: "LR", arrowType: "normal", defaultGap: 15, curveLength: 40, lengthBetweenElAndLine: 100, enableFixedPoint: true, theme: "rainbow", customTheme: { rootBg: FALLBACK_THEMES["custom"].rootBg, colors: [...FALLBACK_THEMES["custom"].colors], levels: JSON.parse(JSON.stringify(DEFAULT_LEVELS)) } }; let engineSettings = ExcalidrawAutomate.plugin.settings.scriptEngineSettings["Mindmap_Engine"] || {}; if (!engineSettings["Mindmap Configuration"]) { engineSettings["Mindmap Configuration"] = { value: JSON.stringify({ themes: FALLBACK_THEMES, defaultSettings: FALLBACK_SETTINGS }), description: t("setting_desc") }; ExcalidrawAutomate.plugin.settings.scriptEngineSettings["Mindmap_Engine"] = engineSettings; (async () => { await ExcalidrawAutomate.plugin.saveSettings(); })(); } let mindmapConfig; try { mindmapConfig = JSON.parse(engineSettings["Mindmap Configuration"].value); } catch (e) { console.error(t("log_parse_fail"), e); mindmapConfig = { themes: FALLBACK_THEMES, defaultSettings: FALLBACK_SETTINGS }; } const THEMES = mindmapConfig.themes || FALLBACK_THEMES; const DEFAULT_SETTINGS = mindmapConfig.defaultSettings || FALLBACK_SETTINGS; function lightenHex(hex, percent) { if (!hex || hex === "transparent") return hex; let num = parseInt(hex.replace("#",""), 16); if (isNaN(num)) return hex; let amt = Math.round(2.55 * percent); let R = (num >> 16) + amt; let G = (num >> 8 & 0x00FF) + amt; let B = (num & 0x0000FF) + amt; return "#" + (0x1000000 + (R<255?R<1?0:R:255)*0x10000 + (G<255?G<1?0:G:255)*0x100 + (B<255?B<1?0:B:255)).toString(16).slice(1); } function resolveStyle(level, branchIndex, settings) { const themeKey = settings?.theme || "rainbow"; let theme = THEMES[themeKey] || THEMES["rainbow"]; if (themeKey === "custom" && settings?.customTheme) { theme = { ...theme, ...settings.customTheme }; } const levels = theme.levels || DEFAULT_LEVELS; const lvlConfig = level <= levels.length ? levels[level - 1] : levels[levels.length - 1]; let bg = "transparent"; if (level === 1) { bg = theme.rootBg; } else { const colors = theme.colors && theme.colors.length > 0 ? theme.colors : THEMES["rainbow"].colors; const baseColor = colors[branchIndex % colors.length]; bg = level > 2 ? lightenHex(baseColor, Math.min((level - 2) * 15, 60)) : baseColor; } return { backgroundColor: bg, textColor: lvlConfig.textColor, strokeColor: lvlConfig.strokeColor, fontSize: lvlConfig.fontSize, strokeWidth: lvlConfig.strokeWidth }; } const MindmapState = { mindmapPosMap: new Map(), treeElements: [], hoveredMindmapElement: null, isRealDragging: false, pointerDownPos: null, currentSelectedNode: null, currentApp: null, currentEA: null }; const getExpandedTreeElements = (baseItems, elementsMap, allElements) => { const expandedMap = new Map(); const addGroupElements = (el) => { if (!el.groupIds?.length || !allElements) return; for (let i = 0; i < allElements.length; i++) { const a = allElements[i]; if (!expandedMap.has(a.id) && a.groupIds?.some(gid => el.groupIds.includes(gid))) { expandedMap.set(a.id, a); } } }; baseItems.forEach(item => { if (!item || !item.id) return; const el = elementsMap.get(item.id); if (!el) return; expandedMap.set(el.id, el); if (el.boundElements) { for (let i = 0; i < el.boundElements.length; i++) { const b = el.boundElements[i]; if (b.type !== "arrow") { const boundEl = elementsMap.get(b.id); if (boundEl) expandedMap.set(boundEl.id, boundEl); } } } addGroupElements(el); }); return Array.from(expandedMap.values()); }; const getElementAbsoluteCoords = (element) => [element.x, element.y, element.x + element.width, element.y + element.height]; const checkOverlap = (el1, el2) => { if (!el1 || !el2 || el1.id === el2.id) return false; const [x1, y1, x2, y2] = getElementAbsoluteCoords(el1); const [tx1, ty1, tx2, ty2] = getElementAbsoluteCoords(el2); return !(x2 < tx1 || x1 > tx2 || y2 < ty1 || y1 > ty2); }; const isDescendant = (potentialParentId, targetId, elementsMap) => { let current = elementsMap.get(targetId); const visited = new Set(); while (current?.customData?.mindmap?.parent) { if (current.customData.mindmap.parent === potentialParentId) return true; if (current.customData.mindmap.parent === current.id || visited.has(current.id)) { break; } visited.add(current.id); current = elementsMap.get(current.customData.mindmap.parent); } return false; }; const getDropZone = (dragged, target, direction = "LR") => { const [dx1, dy1, dx2, dy2] = getElementAbsoluteCoords(dragged); const [tx1, ty1, tx2, ty2] = getElementAbsoluteCoords(target); const dCenterY = ((dy1 + dy2) / 2) - ((ty1 + ty2) / 2); const dCenterX = ((dx1 + dx2) / 2) - ((tx1 + tx2) / 2); if (direction === "LR" || direction === "RL") { if (direction === "LR" && dCenterX > 0 && Math.abs(dCenterX) > Math.abs(dCenterY)) return "child"; if (direction === "RL" && dCenterX < 0 && Math.abs(dCenterX) > Math.abs(dCenterY)) return "child"; return dCenterY < 0 ? "sibling-before" : "sibling-after"; } else { if (direction === "TD" && dCenterY > 0 && Math.abs(dCenterY) > Math.abs(dCenterX)) return "child"; if (direction === "BU" && dCenterY < 0 && Math.abs(dCenterY) > Math.abs(dCenterX)) return "child"; return dCenterX < 0 ? "sibling-before" : "sibling-after"; } }; const attachNodeToMindmap = async (draggedEl, targetEl, dropZone, App, ea) => { const elementsMap = App.scene.getNonDeletedElementsMap(); const allElements = App.scene.getNonDeletedElements(); const rootId = targetEl.customData.mindmap.root; const rootEl = elementsMap.get(rootId); let rootSettings = rootEl?.customData?.mindmap?.settings || DEFAULT_SETTINGS; const direction = rootSettings.direction || "LR"; let newParentId = dropZone === "child" ? targetEl.id : targetEl.customData.mindmap.parent; if (!newParentId) newParentId = rootId; const newParentEl = elementsMap.get(newParentId); let shouldHide = newParentEl?.customData?.hide; if (dropZone === "child") { if (!ea.getElement(newParentId)) ea.copyViewElementsToEAforEditing([newParentEl]); const eaParent = ea.getElement(newParentId); if (eaParent) { eaParent.customData = { ...eaParent.customData, mindmap: { ...(eaParent.customData?.mindmap || {}), status: "open" } }; } shouldHide = newParentEl?.customData?.hide; } else { shouldHide = newParentEl?.customData?.hide || newParentEl?.customData?.mindmap?.status === "close"; } const newLevel = (newParentEl?.customData?.mindmap?.level || 0) + 1; let arrowIdsToDelete = []; for(let i = 0; i < allElements.length; i++){ const el = allElements[i]; if (el.type === "arrow" && el.endBinding?.elementId === draggedEl.id) { arrowIdsToDelete.push(el.id); } } const oldParentId = draggedEl.customData?.mindmap?.parent; const descendantUpdates = []; const visitedDescendants = new Set(); const findDescendants = (parentId, currentLevel) => { if (visitedDescendants.has(parentId)) return; visitedDescendants.add(parentId); for (let i = 0; i < allElements.length; i++) { const child = allElements[i]; if (child.customData?.mindmap?.parent === parentId) { descendantUpdates.push({ child, level: currentLevel + 1 }); findDescendants(child.id, currentLevel + 1); } } }; findDescendants(draggedEl.id, newLevel); ea.clear(); let elementsToEdit = [draggedEl, ...descendantUpdates.map(d => d.child)]; const relatedEls = []; for (let i = 0; i < elementsToEdit.length; i++) { const el = elementsToEdit[i]; if (el.boundElements) { for (let j = 0; j < el.boundElements.length; j++) { const b = el.boundElements[j]; if (b.type !== "arrow") { const bEl = elementsMap.get(b.id); if (bEl) relatedEls.push(bEl); } } } } elementsToEdit = [...new Set([...elementsToEdit, ...relatedEls])]; ea.copyViewElementsToEAforEditing(elementsToEdit); if (shouldHide) { elementsToEdit.forEach(el => { const eaEl = ea.getElement(el.id); if (eaEl) eaEl.customData = { ...(eaEl.customData || {}), hide: true }; }); } const eaDragged = ea.getElement(draggedEl.id); if (eaDragged) { if (dropZone === "sibling-before") { if (direction === "LR" || direction === "RL") { eaDragged.y = targetEl.y - eaDragged.height / 2; eaDragged.x = targetEl.x; } else { eaDragged.x = targetEl.x - eaDragged.width / 2; eaDragged.y = targetEl.y; } } else if (dropZone === "sibling-after") { if (direction === "LR" || direction === "RL") { eaDragged.y = targetEl.y + targetEl.height + eaDragged.height / 2; eaDragged.x = targetEl.x; } else { eaDragged.x = targetEl.x + targetEl.width + eaDragged.width / 2; eaDragged.y = targetEl.y; } } // 注意:无需在此再做复杂的 branchIndex 推算,排版引擎会全局修正 eaDragged.customData = { ...eaDragged.customData, mindmap: { ...(eaDragged.customData?.mindmap || {}), status: eaDragged.customData?.mindmap?.status || "open", root: rootId, parent: newParentId, level: newLevel } }; } descendantUpdates.forEach(d => { const eaChild = ea.getElement(d.child.id); if (eaChild) { eaChild.customData = { ...eaChild.customData, mindmap: { ...(eaChild.customData?.mindmap || {}), root: rootId, level: d.level } }; } }); if (!ea.getElement(newParentId)) ea.copyViewElementsToEAforEditing([newParentEl]); let startPort = "right", endPort = "left"; if (direction === "RL") { startPort = "left"; endPort = "right"; } else if (direction === "TD") { startPort = "bottom"; endPort = "top"; } else if (direction === "BU") { startPort = "top"; endPort = "bottom"; } const arrowId = ea.connectObjects(newParentId, startPort, draggedEl.id, endPort, { startArrowHead: null }); const eaArrow = ea.getElement(arrowId); if (eaArrow) { eaArrow.strokeWidth = rootSettings.strokeWidth || 1.5; eaArrow.roughness = rootEl?.roughness ?? 0; eaArrow.roundness = rootEl?.roundness; if (shouldHide) eaArrow.customData = { ...(eaArrow.customData || {}), hide: true }; } if (oldParentId) { let eaOldParent = ea.getElement(oldParentId); if (!eaOldParent) { const oldParentEl = elementsMap.get(oldParentId); if (oldParentEl) { ea.copyViewElementsToEAforEditing([oldParentEl]); eaOldParent = ea.getElement(oldParentId); } } if (eaOldParent && eaOldParent.boundElements) { eaOldParent.boundElements = eaOldParent.boundElements.filter(b => b.type !== "arrow" || !arrowIdsToDelete.includes(b.id)); if (eaOldParent.boundElements.length === 0) eaOldParent.boundElements = null; } } if (arrowIdsToDelete.length > 0) { const arrowsToEdit = arrowIdsToDelete.map(id => elementsMap.get(id)).filter(Boolean); ea.copyViewElementsToEAforEditing(arrowsToEdit); arrowsToEdit.forEach(arr => { const eaArr = ea.getElement(arr.id); if (eaArr) eaArr.isDeleted = true; }); } await ea.addElementsToView(false, false, true); setTimeout(async () => { try { const updatedElementsMap = App.scene.getNonDeletedElementsMap(); const updatedElements = App.scene.getNonDeletedElements(); const freshRootEl = updatedElementsMap.get(rootId); if (!freshRootEl) return; const mindmap = new Mindmap(updatedElements, freshRootEl); mindmap.setElements(updatedElements, updatedElementsMap); mindmap.buildNodeTree(freshRootEl, null, 1, null); MindmapState.mindmapPosMap.set(rootId, mindmap); const rootNode = mindmap.NodeMap.get(rootId); if (!rootNode) return; const [children, arrows] = mindmap.getChildren(rootNode, ea, true, (el) => updatedElementsMap.get(el.id)?.customData?.hide); const freshDragged = updatedElementsMap.get(draggedEl.id); const freshArrow = updatedElementsMap.get(arrowId); const descendantEls = descendantUpdates.map(d => updatedElementsMap.get(d.child.id)); const extraItems = [freshDragged, freshArrow, ...descendantEls].filter(Boolean); const rawItems = [...new Set([...children, ...arrows, rootNode, ...extraItems])]; const treeElements = getExpandedTreeElements(rawItems, updatedElementsMap, updatedElements); mindmap.clearElements(); await window.MindmapAPI.runLayout(treeElements, true, ea); } catch(e) { console.error("Mindmap auto-layout failed:", e); } }, 100); }; const isPointHittingMindmapIcon = (element, elementsMap, x, y, appState) => { if (!element?.customData?.mindmap?.root) return false; let hasStartArrow = false; if (element.boundElements) { for (let i = 0; i < element.boundElements.length; i++) { const el = element.boundElements[i]; if (el.type === "arrow") { const arrowEl = elementsMap.get(el.id); if (arrowEl?.startBinding?.elementId === element.id) { hasStartArrow = true; break; } } } } if (!hasStartArrow) return false; const isMobile = ExcalidrawAutomate?.obsidian?.Platform?.isMobile || appState?.stylesPanelMode === "mobile" || false; const zoom = appState?.zoom?.value || 1; const baseTargetSize = isMobile ? 36 : 24; const effectiveRadius = Math.max(12, (baseTargetSize / 2) / zoom); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const rootEl = elementsMap.get(element.customData.mindmap.root); const direction = rootEl?.customData?.mindmap?.settings?.direction || "LR"; let iconCenterX = x2, iconCenterY = y1 + element.height / 2; switch (direction) { case "LR": iconCenterX = x2; iconCenterY = y1 + element.height / 2; break; case "RL": iconCenterX = x1; iconCenterY = y1 + element.height / 2; break; case "TD": iconCenterX = x1 + element.width / 2; iconCenterY = y2; break; case "BU": iconCenterX = x1 + element.width / 2; iconCenterY = y1; break; default: iconCenterX = x2; iconCenterY = y1 + element.height / 2; break; } const dist = Math.hypot(x - iconCenterX, y - iconCenterY); return dist <= effectiveRadius; }; const isPointHittingMindmap = (element, elementsMap, appState, x, y) => { if (!element?.customData?.mindmap) return false; if (appState?.viewModeEnabled) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); if (x >= x1 && x <= x2 && y >= y1 && y <= y2) return true; } return isPointHittingMindmapIcon(element, elementsMap, x, y, appState); }; const findHitMindmapElement = (App, scenePointer) => { if (!App || !scenePointer) return null; const elementsMap = App.scene.getNonDeletedElementsMap(); const elements = App.scene.getNonDeletedElements(); for (let i = elements.length - 1; i >= 0; i--) { const el = elements[i]; if (el?.customData?.mindmap && isPointHittingMindmap(el, elementsMap, App.state, scenePointer.x, scenePointer.y)) { return el; } } return null; }; class MindmapNode { constructor(element, rootId, parentId, level) { this.parent = null; this.parentArrow = null; this.children = []; this.childrenArrow = []; this.level = level; this.id = element.id; if (!element?.customData?.mindmap) element.customData = { ...element.customData, mindmap: { status: "open", root: rootId, parent: parentId, level: level } }; } } class Mindmap { constructor(elements, element) { this.rootId = element.customData.mindmap.root; this.NodeMap = new Map(); this.visitedNodes = new Set(); } clearElements() { this.elements = []; this.elementsMap = []; this.arrowElements = []; } setElements(elements, elementsMap) { this.elements = elements; this.elementsMap = elementsMap; this.arrowElements = elements.filter((el) => el.type == "arrow"); } buildNodeTree(element, parentNode, level, arrow) { if (!element || this.visitedNodes.has(element.id)) return; this.visitedNodes.add(element.id); let startArrowElements = this.arrowElements.filter((el) => el.startBinding && el.startBinding.elementId == element.id); let node = new MindmapNode(element, this.rootId, parentNode?.id, (parentNode?.level || 0) + 1); if (level == 1) this.rootNode = node; this.NodeMap.set(node.id, node); node.parent = parentNode; parentNode && parentNode?.children.push(node); node.parentArrow = arrow; startArrowElements.forEach((el) => { if (el.endBinding?.elementId) { node.childrenArrow.push(el); let child = this.elementsMap?.get(el.endBinding.elementId); this.buildNodeTree(child, node, level+1, el); } }); } getChildren(node, ea, recursion=true, filter=undefined) { const children = []; const arrows = []; if (filter && node.children.length) { const firstChildEl = this.elementsMap.get(node.children[0].id); if (firstChildEl && filter(firstChildEl)) return [[], []]; } children.push(...node?.children); arrows.push(...node?.childrenArrow); const childrenEls = children.map((el) => this.elementsMap?.get(el.id)).filter(el => el); let groupElements = []; for (let i = 0; i < childrenEls.length; i++) { const groups = ea.getElementsInTheSameGroupWithElement(childrenEls[i], this.elements); for (let j = 0; j < groups.length; j++) { if (!groups[j]?.customData?.zoomHide && !groups[j]?.customData?.mindmap) { groupElements.push(groups[j]); } } } children.push(...groupElements); if (recursion) { node.children.forEach(el => { const [_children, _arrows] = this.getChildren(el, ea, true, filter); children.push(..._children); arrows.push(..._arrows); }); } return [children, arrows]; } async changeVisibility(element, currentNode, recursion, visiable, status, ea) { const [children, arrows] = this.getChildren(currentNode, ea, recursion); const childrenEls = children.map((el) => this.elementsMap?.get(el.id)).filter(el => el); const arrowEls = arrows.map((el) => this.elementsMap?.get(el.id)).filter(el => el); const boundElements = []; childrenEls.forEach(el => { if (el.boundElements) { el.boundElements.forEach(bound => { if (bound.type != "arrow") boundElements.push(this.elementsMap?.get(bound.id)); }); } }); let groupElements = []; for (let i = 0; i < childrenEls.length; i++) { const groups = ea.getElementsInTheSameGroupWithElement(childrenEls[i], this.elements); for (let j = 0; j < groups.length; j++) { if (!groups[j]?.customData?.zoomHide && !groups[j]?.customData?.mindmap) { groupElements.push(groups[j]); } } } element?.customData?.mindmap && (element.customData.mindmap.status = status); if (status === "close" || (status === "open" && recursion)) { childrenEls.forEach((el) => { el = this.elementsMap?.get(el.id); if (el?.customData?.mindmap) { el.customData.mindmap.status = status; } }); } ea.clear(); let changedVisiableEls = [...new Set([...childrenEls, ...groupElements, ...arrowEls, ...boundElements])]; changedVisiableEls.forEach((el) => { el.customData = el.customData || {}; el.customData.hide = !visiable; }); ea.copyViewElementsToEAforEditing([...changedVisiableEls, element]); await ea.addElementsToView(false, false, false); } async addNewNode(element, currentNode, pos, state, ea) { const newEls = []; let parentNode, level, newElX, newElY; const rootEl = this.elementsMap?.get(this.rootId); let rootSettings = rootEl?.customData?.mindmap?.settings || DEFAULT_SETTINGS; const direction = rootSettings.direction || "LR"; const isHorizontal = ["LR", "RL"].includes(direction); if (pos == "next") { parentNode = currentNode; level = element.customData.mindmap.level+1; newElX = direction === "RL" ? element.x - 100 : element.x + element.width + 20; newElY = element.y; if (currentNode.children && currentNode.children.length > 0) { let maxVal = -Infinity; currentNode.children.forEach(c => { const childEl = this.elementsMap.get(c.id); if (childEl) { const val = isHorizontal ? childEl.y : childEl.x; if (val > maxVal) maxVal = val; } }); if (maxVal !== -Infinity) { if (isHorizontal) newElY = maxVal + 100; else newElX = maxVal + 100; } } } if (pos =="same") { parentNode = currentNode.parent; level = element.customData.mindmap.level; if (isHorizontal) { newElX = element.x; newElY = element.y + element.height + 20; } else { newElX = element.x + element.width + 20; newElY = element.y; } } let parentEl = this.elementsMap?.get(parentNode.id); ea.style.opacity = parentEl?.opacity || 100; ea.style.roughness = element.roughness || 0; ea.style.fillStyle = element.fillStyle || "solid"; ea.style.roundness = element.roundness; ea.style.fontFamily = state.currentItemFontFamily; const newElId = ea.addText(newElX, newElY, "new element", {box:true, textAlign: "center", textVerticalAlign: "middle", boxStrokeColor: "transparent"}); const newEl = ea.getElement(newElId); this.elementsMap.set(newEl.id, newEl); this.elements.push(newEl); const boundElId = newEl.boundElements[0].id; const boundElement = ea.getElement(boundElId); this.elementsMap.set(boundElId, boundElement); this.elements.push(boundElement); if(pos == "same") { newEl.width = element.width; newEl.height = element.height; } const newNode = new MindmapNode(newEl, this.rootId, parentNode?.id, level); parentEl.boundElements = parentEl?.boundElements?.length != 0 ? parentEl.boundElements : null; ea.copyViewElementsToEAforEditing([parentEl]); const arrowId = ea.connectObjects(parentNode.id, "right", newEl.id, "left", {startArrowHead: null}); let arrowEl = ea.getElement(arrowId); this.elementsMap.set(arrowEl.id, arrowEl); this.elements.push(arrowEl); await ea.addElementsToView(false, false, false); newNode.parent = parentNode; newNode.parentArrow = arrowEl; this.NodeMap.set(newNode.id, newNode); this.elementsMap.set(parentNode.id, ea.getElement(parentNode.id)); if (parentNode) { parentNode.childrenArrow.push(arrowEl); parentNode.children.push(newNode); } newEls.push(newEl); newEls.push(arrowEl); return [newEl, newEls]; } } class MindMapLayouter { constructor(elements, config) { this.elements = elements; this.config = config; this.changedElementsSet = new Set(); this.elIdMap = new Map(); this.nodeMap = new Map(); this.visited = new Set(); } runLayout() { if (!this.elements?.length) return; for (let i = 0; i < this.elements.length; i++) { const el = this.elements[i]; this.elIdMap.set(el.id, el); if (!["arrow", "line"].includes(el.type)) this.nodeMap.set(el.id, this.createNode(el)); } for (let i = 0; i < this.elements.length; i++) { const el = this.elements[i]; if (!["arrow", "line"].includes(el.type)) continue; const startId = el.startBinding?.elementId; const endId = el.endBinding?.elementId; if (startId && endId && this.nodeMap.has(startId) && this.nodeMap.has(endId)) { const parentNode = this.nodeMap.get(startId); const childNode = this.nodeMap.get(endId); parentNode.children.push(childNode); parentNode.linkChildrensLines.push(el); childNode.parent = parentNode; } } const roots = [...this.nodeMap.values()].filter((node) => !node.parent); for (let i = 0; i < roots.length; i++) { const rootNode = roots[i]; this.visited.clear(); this.calculateDimensions(rootNode); // [新增]: 全局结构排序及颜色动态指派 const sortAndColor = (node, level, branchIndex) => { node.level = level; node.branchIndex = branchIndex; // 必须在对当前节点处理样式前,先基于物理坐标流式排序它的子节点 node.children.sort((a, b) => this.isHorizontal() ? ((a.el.y + a.el.height / 2) - (b.el.y + b.el.height / 2)) : ((a.el.x + a.el.width / 2) - (b.el.x + b.el.width / 2))); // 应用全局严格推算的样式结构 const style = resolveStyle(level, branchIndex, this.config); this.updateNodeStyle(node, style); // 向下递归传递该基准分支对应的 branchIndex node.children.forEach((child, idx) => { const childBranchIdx = (level === 1) ? idx : branchIndex; sortAndColor(child, level + 1, childBranchIdx); }); }; sortAndColor(rootNode, 1, 0); let startX = rootNode.el.x; let startY = rootNode.el.y; if (this.isHorizontal()) { startY = rootNode.el.y + rootNode.el.height / 2 - rootNode.totalHeight / 2; } else { startX = rootNode.el.x + rootNode.el.width / 2 - rootNode.totalWidth / 2; } this.assignPositions(rootNode, startX, startY); } } createNode(el) { return { el, parent: null, children: [], linkChildrensLines: [], totalHeight: 0, totalWidth: 0, childrenTotalHeight: 0, childrenTotalWidth: 0, level: 1, branchIndex: 0 }; } isHorizontal() { return ["LR", "RL"].includes(this.config.direction); } calculateDimensions(node) { if (this.visited.has(node.el.id)) return; this.visited.add(node.el.id); if (node.el.customData?.hide) { node.totalHeight = 0; node.totalWidth = 0; node.childrenTotalHeight = 0; node.childrenTotalWidth = 0; for (let i = 0; i < node.children.length; i++) { this.calculateDimensions(node.children[i]); } return; } if (!node.children.length) { node.totalHeight = node.el.height + this.config.defaultGap; node.totalWidth = node.el.width + this.config.defaultGap; node.childrenTotalHeight = 0; node.childrenTotalWidth = 0; return; } let sumH = 0, sumW = 0; for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; this.calculateDimensions(child); sumH += child.totalHeight; sumW += child.totalWidth; } node.childrenTotalHeight = sumH; node.childrenTotalWidth = sumW; if (this.isHorizontal()) { node.totalHeight = Math.max(node.el.height + this.config.defaultGap, sumH); } else { node.totalWidth = Math.max(node.el.width + this.config.defaultGap, sumW); } } updateNodeStyle(node, style) { if (!style || node.el.type === "arrow" || node.el.type === "line") return; if (node.el.customData && node.el.customData.mindmap) { if (node.el.customData.mindmap.level !== node.level) { node.el.customData = { ...node.el.customData, mindmap: { ...node.el.customData.mindmap, level: node.level } }; this.changedElementsSet.add(node.el); } } if (node.el.backgroundColor !== style.backgroundColor) { node.el.backgroundColor = style.backgroundColor; this.changedElementsSet.add(node.el); } if (node.el.strokeColor !== style.strokeColor && style.strokeColor) { node.el.strokeColor = style.strokeColor; this.changedElementsSet.add(node.el); } if (node.el.strokeWidth !== style.strokeWidth && style.strokeWidth) { node.el.strokeWidth = style.strokeWidth; this.changedElementsSet.add(node.el); } if (node.el.type === "text" && node.el.fontSize !== style.fontSize) { node.el.fontSize = style.fontSize; this.changedElementsSet.add(node.el); } if (node.el.boundElements) { node.el.boundElements.forEach(b => { if (b.type === "text") { const textEl = this.elIdMap.get(b.id); if (textEl) { if (textEl.strokeColor !== style.textColor && style.textColor) { textEl.strokeColor = style.textColor; this.changedElementsSet.add(textEl); } if (textEl.fontSize !== style.fontSize) { textEl.fontSize = style.fontSize; this.changedElementsSet.add(textEl); } } } }); } } assignPositions(node, bboxX, bboxY) { node.children.sort((a, b) => this.isHorizontal() ? ((a.el.y + a.el.height / 2) - (b.el.y + b.el.height / 2)) : ((a.el.x + a.el.width / 2) - (b.el.x + b.el.width / 2))); const sortedLines = []; node.children.forEach(child => { const line = node.linkChildrensLines.find(l => l.endBinding?.elementId === child.el.id || l.startBinding?.elementId === child.el.id); if (line) sortedLines.push(line); }); node.linkChildrensLines = sortedLines; let newX = node.el.x; let newY = node.el.y; if (this.isHorizontal()) { newY = bboxY + node.totalHeight / 2 - node.el.height / 2; if (node.parent) newX = bboxX; } else { newX = bboxX + node.totalWidth / 2 - node.el.width / 2; if (node.parent) newY = bboxY; } const dx = newX - node.el.x; const dy = newY - node.el.y; if (dx !== 0 || dy !== 0) { node.el.x = newX; node.el.y = newY; this.changedElementsSet.add(node.el); if (["rectangle", "diamond", "ellipse"].includes(node.el.type)) { const textDesc = (node.el.boundElements ?? []).find(b => b.type === "text"); if (textDesc) { const textEl = this.elIdMap.get(textDesc.id); if (textEl) { textEl.x += dx; textEl.y += dy; this.changedElementsSet.add(textEl); } } } if (node.el.groupIds?.length) { this.elements.filter(item => item.groupIds?.some(gid => node.el.groupIds.includes(gid))).forEach(g => { if (g.id !== node.el.id) { g.x += dx; g.y += dy; this.changedElementsSet.add(g); } }); } if (node.el.type === "frame") { this.elements.filter(item => item.frameId === node.el.id).forEach(g => { g.x += dx; g.y += dy; this.changedElementsSet.add(g); }); } if (node.el.customData?.swimlane) { this.elements.filter(item => item.customData?.parent === node.el.id).forEach(g => { g.x += dx; g.y += dy; this.changedElementsSet.add(g); }); } } if (node.children.length === 0) return; let currentChildBboxY = bboxY + (node.totalHeight - node.childrenTotalHeight) / 2; let currentChildBboxX = bboxX + (node.totalWidth - node.childrenTotalWidth) / 2; for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; const line = node.linkChildrensLines[i]; let nextBboxX = bboxX; let nextBboxY = bboxY; if (this.config.direction === "LR") { nextBboxX = node.el.x + node.el.width + this.config.lengthBetweenElAndLine + this.config.curveLength; nextBboxY = currentChildBboxY; currentChildBboxY += child.totalHeight; } else if (this.config.direction === "RL") { nextBboxX = node.el.x - this.config.lengthBetweenElAndLine - this.config.curveLength - child.el.width; nextBboxY = currentChildBboxY; currentChildBboxY += child.totalHeight; } else if (this.config.direction === "TD") { nextBboxY = node.el.y + node.el.height + this.config.lengthBetweenElAndLine + this.config.curveLength; nextBboxX = currentChildBboxX; currentChildBboxX += child.totalWidth; } else if (this.config.direction === "BU") { nextBboxY = node.el.y - this.config.lengthBetweenElAndLine - this.config.curveLength - child.el.height; nextBboxX = currentChildBboxX; currentChildBboxX += child.totalWidth; } this.assignPositions(child, nextBboxX, nextBboxY); if (line) this.updateLine(node, child, line); } } updateLine(parentNode, childNode, line) { line.x = parentNode.el.x + parentNode.el.width; line.y = parentNode.el.y + parentNode.el.height / 2; if (this.config.direction === "RL") { line.x = parentNode.el.x; } else if (this.config.direction === "TD") { line.x = parentNode.el.x + parentNode.el.width / 2; line.y = parentNode.el.y + parentNode.el.height; } else if (this.config.direction === "BU") { line.x = parentNode.el.x + parentNode.el.width / 2; line.y = parentNode.el.y; } line.startBinding = { elementId: parentNode.el.id, focus: 0, gap: 1 }; line.endBinding = { elementId: childNode.el.id, focus: 0, gap: 1 }; if (this.config.enableFixedPoint) { line.startBinding.mode = "inside"; line.endBinding.mode = "inside"; if (this.config.direction === "LR") { line.startBinding.fixedPoint = [1.0, 0.5]; line.endBinding.fixedPoint = [0.0, 0.5]; } else if (this.config.direction === "RL") { line.startBinding.fixedPoint = [0.0, 0.5]; line.endBinding.fixedPoint = [1.0, 0.5]; } else if (this.config.direction === "TD") { line.startBinding.fixedPoint = [0.5, 1.0]; line.endBinding.fixedPoint = [0.5, 0.0]; } else if (this.config.direction === "BU") { line.startBinding.fixedPoint = [0.5, 0.0]; line.endBinding.fixedPoint = [0.5, 1.0]; } } else { delete line.startBinding.fixedPoint; delete line.startBinding.mode; delete line.endBinding.fixedPoint; delete line.endBinding.mode; } let childConnX = childNode.el.x; let childConnY = childNode.el.y + childNode.el.height / 2; if (this.config.direction === "RL") childConnX = childNode.el.x + childNode.el.width; else if (this.config.direction === "TD") { childConnX = childNode.el.x + childNode.el.width / 2; childConnY = childNode.el.y; } else if (this.config.direction === "BU") { childConnX = childNode.el.x + childNode.el.width / 2; childConnY = childNode.el.y + childNode.el.height; } const dx = childConnX - line.x; const dy = childConnY - line.y; if (this.config.arrowType === "elbow") { line.elbowed = true; if (this.isHorizontal()) { line.points = [[0, 0], [dx / 2, 0], [dx / 2, dy], [dx, dy]]; } else { line.points = [[0, 0], [0, dy / 2], [dx, dy / 2], [dx, dy]]; } } else { line.elbowed = false; const isAligned = this.isHorizontal() ? Math.abs(dy) < 1 : Math.abs(dx) < 1; if (line.customData?.curveArrow || parentNode.children.length === 1 || isAligned) { line.points = [ [0, 0], [dx, dy] ]; } else { if (this.isHorizontal()) { line.points = [ [0, 0], [dx * 0.4, dy * 0.8905], [dx, dy] ]; } else { line.points = [ [0, 0], [dx * 0.8905, dy * 0.4], [dx, dy] ]; } } } const style = resolveStyle(childNode.level, childNode.branchIndex, this.config); if (style) { if (line.strokeColor !== style.strokeColor && style.strokeColor) { line.strokeColor = style.strokeColor; this.changedElementsSet.add(line); } if (line.strokeWidth !== style.strokeWidth && style.strokeWidth) { line.strokeWidth = style.strokeWidth; this.changedElementsSet.add(line); } } this.changedElementsSet.add(line); } } window.MindmapAPI = { runLayout: async (sceneElements, commitToHistory = false, activeEa = null) => { if (!activeEa) { console.warn("MindmapAPI.runLayout: No EA instance provided."); return; } const rootEl = sceneElements.find(el => el.customData?.mindmap?.root === el.id); const rootSettings = rootEl?.customData?.mindmap?.settings || DEFAULT_SETTINGS; const notArrowEls = sceneElements.filter((el) => el.type !== "arrow"); const arrowEls = sceneElements.filter((el) => el.type === "arrow"); activeEa.clear(); if (notArrowEls.length > 0) activeEa.copyViewElementsToEAforEditing(notArrowEls); if (arrowEls.length > 0) activeEa.copyViewElementsToEAforEditing(arrowEls); const eaElements = activeEa.getElements(); const layouter = new MindMapLayouter(eaElements, { direction: rootSettings.direction || "LR", arrowType: rootSettings.arrowType || "normal", enableFixedPoint: rootSettings.enableFixedPoint ?? true, defaultGap: Number(rootSettings.defaultGap ?? 10), curveLength: Number(rootSettings.curveLength ?? 40), lengthBetweenElAndLine: Number(rootSettings.lengthBetweenElAndLine ?? 100), theme: rootSettings.theme, customTheme: rootSettings.customTheme }); layouter.runLayout(); await activeEa.addElementsToView(false, false, commitToHistory, false, "EVENTUALLY"); activeEa.clear(); }, addChildNode: async (selectedEl, App, activeEa = null) => { return window.MindmapAPI.addNode(selectedEl, "next", App, activeEa); }, addSiblingNode: async (selectedEl, App, activeEa = null) => { return window.MindmapAPI.addNode(selectedEl, "same", App, activeEa); }, addNode: async (selectedEl, pos, App, activeEa = null) => { const curEa = activeEa || ExcalidrawAutomate; if (!selectedEl?.customData?.mindmap || !curEa) return null; const elements = curEa.getViewElements(); let elementsMap = App?.scene?.getNonDeletedElementsMap?.() || elements.reduce((acc, el) => acc.set(el.id, el), new Map()); elements.forEach(el => elementsMap.set(el.id, el)); curEa.clear(); let mindmap = MindmapState.mindmapPosMap.get(selectedEl.customData.mindmap.root); const rootEl = elementsMap?.get(selectedEl.customData.mindmap.root); if (!mindmap || !(mindmap.NodeMap.get(selectedEl.id)?.children?.length)) { mindmap = new Mindmap(elements, selectedEl); mindmap.setElements(elements, elementsMap); mindmap.buildNodeTree(rootEl, null, 1, null); MindmapState.mindmapPosMap.set(selectedEl.customData.mindmap.root, mindmap); } else { mindmap.setElements(elements, elementsMap); } const currentNode = mindmap.NodeMap.get(selectedEl.id); const rootNode = mindmap.NodeMap.get(mindmap.rootId); if (!currentNode) return null; const [newEl, newEls] = await mindmap.addNewNode(selectedEl, currentNode, pos, App?.state || {}, curEa); const [children, arrows] = mindmap.getChildren(rootNode, curEa, true, (el) => curEa.getElement(el.id)?.customData?.hide); curEa.selectElementsInView([newEl]); setTimeout(() => { const appInst = App || curEa?.targetView?.excalidrawRef?.current; const startFn = appInst?.startTextEditing?.bind(appInst) || appInst?.text?.startTextEditing?.bind(appInst?.text); if (startFn) { startFn({ sceneX: 0, sceneY: 0, container: newEl }); } }, 50); const rawItems = [...children, ...arrows, rootNode, ...newEls]; const treeElements = getExpandedTreeElements(rawItems, elementsMap, elements); await window.MindmapAPI.runLayout(treeElements, true, curEa); mindmap.clearElements(); return newEl; }, toggleFold: async (selectedEl, App, activeEa = null, recursion = null) => { const curEa = activeEa || ExcalidrawAutomate; if (!selectedEl?.customData?.mindmap || !curEa) return; const elementsMap = App?.scene?.getNonDeletedElementsMap?.() || curEa.getViewElements().reduce((acc, el) => acc.set(el.id, el), new Map()); const elements = App?.scene?.getNonDeletedElements?.() || curEa.getViewElements(); let mindmap = MindmapState.mindmapPosMap.get(selectedEl.customData.mindmap.root); if (!mindmap || !(mindmap.NodeMap.get(selectedEl.id)?.children?.length)) { const rootEl = elementsMap?.get(selectedEl.customData.mindmap.root); mindmap = new Mindmap(elements, selectedEl); mindmap.setElements(elements, elementsMap); mindmap.buildNodeTree(rootEl, null, 1, null); MindmapState.mindmapPosMap.set(selectedEl.customData.mindmap.root, mindmap); } else { mindmap.setElements(elements, elementsMap); } const currentNode = mindmap.NodeMap.get(selectedEl.id); if (!currentNode || !currentNode.children.length) return; const isCurrentlyOpen = selectedEl.customData.mindmap.status === "open"; const doRecursion = recursion !== null ? recursion : (isCurrentlyOpen ? true : false); const visiable = !isCurrentlyOpen; const status = isCurrentlyOpen ? "close" : "open"; await mindmap.changeVisibility(selectedEl, currentNode, doRecursion, visiable, status, curEa); const rootNode = mindmap.NodeMap.get(mindmap.rootId); const [children, arrows] = mindmap.getChildren(rootNode, curEa, true, (el) => mindmap.elementsMap?.get(el.id)?.customData?.hide); const rawItems = [...children, ...arrows, rootNode, selectedEl]; const treeElements = getExpandedTreeElements(rawItems, elementsMap, elements); mindmap.clearElements(); await window.MindmapAPI.runLayout(treeElements, false, curEa); }, startEdit: (selectedEl, App) => { if (!selectedEl) return; const isTextNode = selectedEl.type === "text"; const hasBoundText = selectedEl.boundElements?.some(b => b.type === "text"); if (isTextNode || hasBoundText) { setTimeout(() => { const appInst = App || ExcalidrawAutomate?.targetView?.excalidrawRef?.current; const startFn = appInst?.startTextEditing?.bind(appInst) || appInst?.text?.startTextEditing?.bind(appInst?.text); if (startFn) { const payload = { sceneX: 0, sceneY: 0 }; if (isTextNode) { payload.textElement = selectedEl; } else { payload.container = selectedEl; } startFn(payload); } }, 0); } } }; const handlePointerMove = (context) => { const { ea, api, App, scenePointer } = context; if (!ea || !api) return; if (!App || !scenePointer) return false; const hitMindmapElement = findHitMindmapElement(App, scenePointer); if (hitMindmapElement) { if (App.interactiveCanvas) App.interactiveCanvas.style.cursor = "pointer"; MindmapState.hoveredMindmapElement = hitMindmapElement; return true; } MindmapState.hoveredMindmapElement = null; return false; }; const handlePointerUp = async (context) => { const { ea, api, App, event, scenePointer } = context; if (!ea || !api) return; let isTap = true; if (MindmapState.pointerDownPos && event && typeof event.clientX === "number") { const dist = Math.hypot(event.clientX - MindmapState.pointerDownPos.x, event.clientY - MindmapState.pointerDownPos.y); if (dist > 8) { isTap = false; } } MindmapState.pointerDownPos = null; const elementsMap = App?.scene?.getNonDeletedElementsMap?.(); const allElements = App?.scene?.getNonDeletedElements(); const selectedEls = App.scene.getSelectedElements(App.state); const mainSelectedEls = selectedEls.filter(el => !el.containerId && el.type !== "arrow" && el.type !== "line"); let draggedEl = null; if (mainSelectedEls.length > 0) { draggedEl = mainSelectedEls.reduce((max, el) => { return (el.width * el.height > max.width * max.height) ? el : max; }, mainSelectedEls[0]); } if (draggedEl && MindmapState.isRealDragging && !isTap) { if (draggedEl.customData?.mindmap && draggedEl.customData.mindmap.root === draggedEl.id) { } else { const targetEl = allElements.find(el => el.id !== draggedEl.id && el.customData?.mindmap && !el.customData?.hide && checkOverlap(draggedEl, el) && !isDescendant(draggedEl.id, el.id, elementsMap) ); if (targetEl) { const rootId = targetEl.customData?.mindmap?.root || targetEl.id; const rootEl = elementsMap.get(rootId); const direction = rootEl?.customData?.mindmap?.settings?.direction || "LR"; const dropZone = getDropZone(draggedEl, targetEl, direction); if (App.interactiveCanvas) App.interactiveCanvas.style.cursor = ""; setTimeout(async () => { await attachNodeToMindmap(draggedEl, targetEl, dropZone, App, ea); if (App.interactiveCanvas) App.interactiveCanvas.style.cursor = ""; }, 50); MindmapState.isRealDragging = false; return false; } } } MindmapState.isRealDragging = false; // 仅在真实轻触(isTap)时才触发折叠/展开,避免平移/缩放画布被误识别为点击折叠 if (!isTap) { MindmapState.hoveredMindmapElement = null; return false; } const targetElement = findHitMindmapElement(App, scenePointer) || MindmapState.hoveredMindmapElement; if (ea?.plugin?.settings?.mindmapHandle && targetElement) { if (event && typeof event.preventDefault === "function") { event.preventDefault(); event.stopPropagation(); } const element = targetElement; ea.viewUpdateScene({ appState: { selectedElementIds: [], selectedGroupIds: {}, editingGroupId: null, activeEmbeddable: null } }); const elements = App.scene.getNonDeletedElements(); let mindmap = MindmapState.mindmapPosMap.get(element.customData.mindmap.root); let currentNode = mindmap ? mindmap.NodeMap.get(element.id) : null; if (!MindmapState.mindmapPosMap.get(element.customData.mindmap.root) || (currentNode && !currentNode?.children?.length)) { const rootEl = elementsMap?.get(element.customData.mindmap.root); mindmap = new Mindmap(elements, element); mindmap.setElements(elements, elementsMap); mindmap.buildNodeTree(rootEl, null, 1, null); MindmapState.mindmapPosMap.set(element.customData.mindmap.root, mindmap); } else { mindmap.setElements(elements, elementsMap); } currentNode = mindmap.NodeMap.get(element.id); const isCtrl = event?.ctrlKey; let recursion = element.customData.mindmap.status === "open" ? true : isCtrl; let visiable = element.customData.mindmap.status === "open" ? false : true; let status = element.customData.mindmap.status === "open" ? "close" : "open"; await mindmap.changeVisibility(element, currentNode, recursion, visiable, status, ea); const rootNode = mindmap.NodeMap.get(mindmap.rootId); const [children, arrows] = mindmap.getChildren(rootNode, ea, true, (el) => mindmap.elementsMap?.get(el.id)?.customData?.hide); const rawItems = [...children, ...arrows, rootNode, element]; const treeElements = getExpandedTreeElements(rawItems, elementsMap, elements); mindmap.clearElements(); await window.MindmapAPI.runLayout(treeElements, false, ea); return true; } return false; }; const handleKeyDown = (context) => { const { App, event, ea, api } = context; if (!ea || !api) return; const selectedEls = ea.getViewSelectedElements(); if (!selectedEls.length) return false; const selectedEl = selectedEls.find(el => !el.containerId) || selectedEls[0]; if (event.code === "Space" && selectedEl.customData?.mindmap) { event.preventDefault(); event.stopPropagation(); window.MindmapAPI.startEdit(selectedEl, App); return true; } if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code) && selectedEl.customData?.mindmap) { const elementsMap = App.scene.getNonDeletedElementsMap(); const elements = App.scene.getNonDeletedElements(); const mindmap = new Mindmap(elements, selectedEl); const rootEl = elementsMap.get(selectedEl.customData.mindmap.root); mindmap.setElements(elements, elementsMap); mindmap.buildNodeTree(rootEl, null, 1, null); const node = mindmap.NodeMap.get(selectedEl.id); if (!node) return false; let targetNode = null; const direction = rootEl?.customData?.mindmap?.settings?.direction || "LR"; if (direction === "LR" || direction === "RL") { if (event.code === "ArrowLeft") targetNode = direction === "LR" ? node.parent : node.children.find(c => !elementsMap.get(c.id)?.customData?.hide); if (event.code === "ArrowRight") targetNode = direction === "LR" ? node.children.find(c => !elementsMap.get(c.id)?.customData?.hide) : node.parent; if (event.code === "ArrowUp" || event.code === "ArrowDown") { let sameLevelNodes = Array.from(mindmap.NodeMap.values()).filter(n => { const el = elementsMap.get(n.id); return n.level === node.level && el && !el.customData?.hide; }); sameLevelNodes.sort((a, b) => { const elA = elementsMap.get(a.id); const elB = elementsMap.get(b.id); return (elA ? elA.y + elA.height / 2 : 0) - (elB ? elB.y + elB.height / 2 : 0); }); const idx = sameLevelNodes.findIndex(s => s.id === node.id); if (event.code === "ArrowUp" && idx > 0) targetNode = sameLevelNodes[idx - 1]; if (event.code === "ArrowDown" && idx < sameLevelNodes.length - 1) targetNode = sameLevelNodes[idx + 1]; } } else if (direction === "TD" || direction === "BU") { if (event.code === "ArrowUp") targetNode = direction === "TD" ? node.parent : node.children.find(c => !elementsMap.get(c.id)?.customData?.hide); if (event.code === "ArrowDown") targetNode = direction === "TD" ? node.children.find(c => !elementsMap.get(c.id)?.customData?.hide) : node.parent; if (event.code === "ArrowLeft" || event.code === "ArrowRight") { let sameLevelNodes = Array.from(mindmap.NodeMap.values()).filter(n => { const el = elementsMap.get(n.id); return n.level === node.level && el && !el.customData?.hide; }); sameLevelNodes.sort((a, b) => { const elA = elementsMap.get(a.id); const elB = elementsMap.get(b.id); return (elA ? elA.x + elA.width / 2 : 0) - (elB ? elB.x + elB.width / 2 : 0); }); const idx = sameLevelNodes.findIndex(s => s.id === node.id); if (event.code === "ArrowLeft" && idx > 0) targetNode = sameLevelNodes[idx - 1]; if (event.code === "ArrowRight" && idx < sameLevelNodes.length - 1) targetNode = sameLevelNodes[idx + 1]; } } event.preventDefault(); event.stopPropagation(); if (targetNode) ea.viewUpdateScene({ appState: { selectedElementIds: { [targetNode.id]: true } } }); return true; } if (["Tab", "Enter"].includes(event.code)) { if (selectedEl?.customData?.mindmap) { event.preventDefault(); event.stopPropagation(); window.MindmapAPI.addNode(selectedEl, event.code === "Enter" ? "same" : "next", App, ea); return true; } } if ((event.key === "/" || event.key === "?") && selectedEl?.customData?.mindmap) { event.preventDefault(); event.stopPropagation(); const isCtrl = event.ctrlKey || event.metaKey; const isCurrentlyOpen = selectedEl.customData.mindmap.status === "open"; window.MindmapAPI.toggleFold(selectedEl, App, ea, isCurrentlyOpen ? true : isCtrl); return true; } return false; }; const handlePointerDown = (context) => { const { ea, api, App, event, scenePointer } = context; if (!ea || !api) return; if (event && typeof event.clientX === "number") { MindmapState.pointerDownPos = { x: event.clientX, y: event.clientY }; } const hitMindmapElement = findHitMindmapElement(App, scenePointer) || MindmapState.hoveredMindmapElement; if (ea?.plugin?.settings?.mindmapHandle && hitMindmapElement) { if (event && typeof event.preventDefault === "function") { event.preventDefault(); event.stopPropagation(); } return true; } return false; }; const handleElementsDragged = (context) => { const { ea, api, selectedElements, scene, elementsToUpdate } = context; if (!ea || !api) return; MindmapState.isRealDragging = true; const elementsMap = scene?.getNonDeletedElementsMap?.(); if (selectedElements.length === 1 && selectedElements[0].type === "arrow") { const arrowEl = selectedElements[0]; const startTarget = arrowEl.startBinding?.elementId ? elementsMap.get(arrowEl.startBinding.elementId) : null; const endTarget = arrowEl.endBinding?.elementId ? elementsMap.get(arrowEl.endBinding.elementId) : null; const isMindmapArrow = startTarget?.customData?.mindmap || endTarget?.customData?.mindmap; if (isMindmapArrow) { if (elementsToUpdate && elementsToUpdate.has(arrowEl)) { elementsToUpdate.delete(arrowEl); } return true; } } selectedElements.forEach((el) => { if (el?.customData?.mindmap) { const rootEl = elementsMap?.get(el.customData.mindmap.root); if (rootEl?.customData?.mindmap?.settings?.arrowType == 'normal') { el.boundElements?.forEach(boundElement => { if (boundElement.type == "arrow") { const be = elementsMap?.get(boundElement.id); if (be && be.points && be.points.length >= 3) { if (be.startBinding?.elementId == el.id || be.endBinding?.elementId == el.id) { const newPoints = be.points.map(p => [...p]); newPoints[1][0] = 0.4 * (newPoints[2][0] - newPoints[0][0]); newPoints[1][1] = (newPoints[2][1] - newPoints[0][1]) * 0.8905; be.points = newPoints; } } } }); } } }); return false; }; const handleElementsDeleted = async (context) => { const { ea, api, elements, appState, App } = context; if (!ea || !api) return; ea.setView("active"); const selectedEls = ea.getViewSelectedElements(); const elementsMap = App?.scene?.getNonDeletedElementsMap?.(); let elementsToUpdate = []; let arrowsToDelete = []; let affectedRoots = new Set(); function deleteOrphan(node) { if (node) { let el = elementsMap?.get(node.id); if (el?.customData?.mindmap) { const newCustomData = { ...el.customData }; delete newCustomData.mindmap; el.customData = newCustomData; elementsToUpdate.push(el); node.children.forEach(n => deleteOrphan(n)); } } } selectedEls.forEach(el => { if (el.type === "arrow" && el.endBinding?.elementId) { const targetId = el.endBinding.elementId; const targetEl = elementsMap.get(targetId); if (targetEl?.customData?.mindmap && targetEl.id !== targetEl.customData.mindmap.root) { const rootId = targetEl.customData.mindmap.root; const mindmap = MindmapState.mindmapPosMap.get(rootId); if (mindmap) { affectedRoots.add(rootId); mindmap.setElements(elements, elementsMap); const node = mindmap.NodeMap.get(targetId); if (node && node.parent) { node.parent.childrenArrow = node.parent.childrenArrow.filter(arr => arr.id !== el.id); node.parent.children = node.parent.children.filter(n => n.id !== node.id); deleteOrphan(node); mindmap.NodeMap.delete(node.id); } } } } if (el?.customData?.mindmap && el.id != el.customData.mindmap.root) { const rootId = el.customData.mindmap.root; const mindmap = MindmapState.mindmapPosMap.get(rootId); if (!mindmap) return; affectedRoots.add(rootId); mindmap.setElements(elements, elementsMap); const node = mindmap.NodeMap.get(el.id); const parentNode = node?.parent; if (!parentNode) return; let deleteArrow; let arrows = []; parentNode.childrenArrow.map(arrEl => elementsMap?.get(arrEl.id)).forEach(arrow => { if (arrow?.endBinding?.elementId) { if (arrow.endBinding.elementId != node.id) arrows.push(arrow); else deleteArrow = arrow; } }); parentNode.childrenArrow = arrows; let children = new Set(parentNode.children); children.delete(node); parentNode.children = [...children]; node.children.forEach(child => deleteOrphan(child)); mindmap.NodeMap.delete(node.id); if (deleteArrow) { appState.selectedElementIds[deleteArrow.id] = true; arrowsToDelete.push(deleteArrow); const parentEl = elementsMap?.get(parentNode.id); if (parentEl?.boundElements?.length > 0) { const newBoundElements = parentEl.boundElements.filter(b => !(b.type == "arrow" && b.id == deleteArrow.id)); parentEl.boundElements = newBoundElements.length > 0 ? newBoundElements : null; elementsToUpdate.push(parentEl); } } } }); if (arrowsToDelete.length > 0 || elementsToUpdate.length > 0) { ea.clear(); ea.copyViewElementsToEAforEditing([...arrowsToDelete, ...elementsToUpdate]); ea.getElements().forEach(el => { if (el.type === "arrow" && arrowsToDelete.some(a => a.id === el.id)) el.isDeleted = true; }); await ea.addElementsToView(false, false, false); } for (const rootId of affectedRoots) { const mindmap = MindmapState.mindmapPosMap.get(rootId); if (mindmap) { const rootNode = mindmap.NodeMap.get(rootId); if (rootNode) { const [children, arrows] = mindmap.getChildren(rootNode, ea, true, (el) => mindmap.elementsMap?.get(el.id)?.customData?.hide); const rawItems = [...children, ...arrows, rootNode]; const treeElements = getExpandedTreeElements(rawItems, elementsMap, elements).filter(el => el && !selectedEls.some(sel => sel.id === el.id) && !arrowsToDelete.some(a => a.id === el.id)); mindmap.clearElements(); await window.MindmapAPI.runLayout(treeElements, true, ea); } } } return false; }; const ensureMobileToolbar = (App, ea) => { let toolbar = document.getElementById("ea-mindmap-mobile-toolbar"); if (!toolbar) { const style = document.createElement("style"); style.id = "ea-mindmap-toolbar-style"; style.innerHTML = ` #ea-mindmap-mobile-toolbar { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); display: none; align-items: center; gap: 6px; padding: 6px 12px; background: var(--background-primary, #ffffff); border: 1px solid var(--background-modifier-border, rgba(0,0,0,0.15)); border-radius: 24px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); z-index: 9999; pointer-events: auto; user-select: none; -webkit-user-select: none; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } .ea-mm-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; padding: 6px 12px; border: none; border-radius: 16px; background: var(--background-secondary, rgba(0,0,0,0.06)); color: var(--text-normal, #333); font-size: 13px; font-weight: 500; cursor: pointer; touch-action: manipulation; white-space: nowrap; } .ea-mm-btn:active { background: var(--interactive-accent, #705df2); color: var(--text-on-accent, #ffffff); } .ea-mm-btn span { font-size: 14px; } `; document.head.appendChild(style); toolbar = document.createElement("div"); toolbar.id = "ea-mindmap-mobile-toolbar"; toolbar.innerHTML = ` `; document.body.appendChild(toolbar); toolbar.querySelector("#ea-mm-child").onclick = (e) => { e.stopPropagation(); if (MindmapState.currentSelectedNode) { window.MindmapAPI.addChildNode(MindmapState.currentSelectedNode, MindmapState.currentApp, MindmapState.currentEA); } }; toolbar.querySelector("#ea-mm-sibling").onclick = (e) => { e.stopPropagation(); if (MindmapState.currentSelectedNode) { window.MindmapAPI.addSiblingNode(MindmapState.currentSelectedNode, MindmapState.currentApp, MindmapState.currentEA); } }; toolbar.querySelector("#ea-mm-fold").onclick = (e) => { e.stopPropagation(); if (MindmapState.currentSelectedNode) { window.MindmapAPI.toggleFold(MindmapState.currentSelectedNode, MindmapState.currentApp, MindmapState.currentEA); } }; toolbar.querySelector("#ea-mm-edit").onclick = (e) => { e.stopPropagation(); if (MindmapState.currentSelectedNode) { window.MindmapAPI.startEdit(MindmapState.currentSelectedNode, MindmapState.currentApp); } }; } return toolbar; }; const updateMobileToolbar = (selectedElements, App, ea) => { MindmapState.currentApp = App; MindmapState.currentEA = ea; const isMobile = ExcalidrawAutomate?.obsidian?.Platform?.isMobile || (window.app?.isMobile ?? false) || ('ontouchstart' in window); if (!isMobile) { const existing = document.getElementById("ea-mindmap-mobile-toolbar"); if (existing) existing.style.display = "none"; return; } const toolbar = ensureMobileToolbar(App, ea); if (!toolbar) return; if (selectedElements && selectedElements.length === 1 && selectedElements[0]?.customData?.mindmap) { const el = selectedElements[0]; MindmapState.currentSelectedNode = el; const foldBtn = toolbar.querySelector("#ea-mm-fold"); if (foldBtn) { const isClosed = el.customData.mindmap.status === "close"; foldBtn.innerHTML = isClosed ? `📂 ${t("tb_unfold")}` : `📁 ${t("tb_fold")}`; } toolbar.style.display = "flex"; } else { MindmapState.currentSelectedNode = null; toolbar.style.display = "none"; } }; const handleMindmapRender = (contextPayload) => { const { ea, api, appState, elementsMap, visibleElements, context, selectedElements } = contextPayload; if (!ea || !api) return; try { const selectedEls = selectedElements || ea?.getViewSelectedElements?.() || []; updateMobileToolbar(selectedEls, api, ea); for (let i = 0; i < visibleElements.length; i++) { const element = visibleElements[i]; if (element?.customData?.hide || !element?.customData?.mindmap || ["arrow", "line"].includes(element.type)) continue; if (!ExcalidrawAutomate.plugin.settings.mindmapHandle) continue; let hasStartArrow = false; let isCollapsed = false; if (element.boundElements) { for (let j = 0; j < element.boundElements.length; j++) { const bound = element.boundElements[j]; if (bound.type === "arrow") { const arrowEl = elementsMap.get(bound.id); if (arrowEl && arrowEl.startBinding?.elementId === element.id) { hasStartArrow = true; if (arrowEl.customData?.hide) isCollapsed = true; break; } } } } if (!hasStartArrow) continue; context.save(); context.globalAlpha = 1; const rootEl = elementsMap?.get(element.customData.mindmap.root); let [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const cx = ((x1 + x2) / 2 + appState.scrollX); const cy = ((y1 + y2) / 2 + appState.scrollY); let shiftX = (x2 - x1) / 2 - (element.x - x1); let shiftY = (y2 - y1) / 2 - (element.y - y1); context.scale(appState.zoom.value, appState.zoom.value); context.translate(cx, cy); context.rotate(element.angle); const direction = rootEl?.customData?.mindmap?.settings?.direction || "LR"; switch (direction) { case "LR": context.translate(shiftX, 0); break; case "RL": context.translate(-shiftX, 0); break; case "TD": context.translate(0, shiftY); break; case "BU": context.translate(0, -shiftY); break; default: context.translate(shiftX, 0); break; } context.beginPath(); context.arc(0, 0, 10, 0, 2 * Math.PI, false); context.fillStyle = '#ffd8a8'; context.fill(); context.lineWidth = 3; context.strokeStyle = '#ffa94d'; if (isCollapsed) { context.stroke(); const mindmap = MindmapState.mindmapPosMap.get(element.customData.mindmap.root); const num = mindmap?.NodeMap?.get(element.id)?.children?.length; if (num) { context.fillStyle = "#f08c00"; context.textAlign = "center"; context.textBaseline = "middle"; context.fillText(num, 0, 0); } else { context.beginPath(); context.moveTo(-5, 0); context.lineTo(5, 0); context.moveTo(0, -5); context.lineTo(0, 5); context.stroke(); } } else { context.moveTo(-5, 0); context.lineTo(5, 0); context.stroke(); } context.restore(); } } catch (e) { console.error(`[Feature: MindmapRender] Error:`, e); } return false; }; async function mountFeature() { if (!window.EA_Core) return console.warn(`[${SCRIPT_ID}] EA_Core 未运行`); if (typeof ExcalidrawAutomate.plugin[`disable_${SCRIPT_ID}`] === "function") ExcalidrawAutomate.plugin[`disable_${SCRIPT_ID}`](); window.EA_Core.registerHook(SCRIPT_ID, 'handleCanvasPointerMove', handlePointerMove, 50); window.EA_Core.registerHook(SCRIPT_ID, 'handleCanvasPointerUp', handlePointerUp, 50); window.EA_Core.registerHook(SCRIPT_ID, 'handleCanvasPointerDown', handlePointerDown, 50); window.EA_Core.registerHook(SCRIPT_ID, 'onKeyDown', handleKeyDown, 30); window.EA_Core.registerHook(SCRIPT_ID, 'dragSelectedElements', handleElementsDragged, 50); window.EA_Core.registerHook(SCRIPT_ID, 'deleteSelectedElements', handleElementsDeleted, 50); window.EA_Core.registerHook(SCRIPT_ID, 'renderInteractiveScene', handleMindmapRender, 50); ExcalidrawAutomate.plugin[`disable_${SCRIPT_ID}`] = () => { if (window.EA_Core) { window.EA_Core.unregisterHook(SCRIPT_ID); MindmapState.mindmapPosMap.clear(); const excalidrawAPI = ExcalidrawAutomate.api || window.ExcalidrawAutomate?.api; if (excalidrawAPI) { excalidrawAPI.updateScene({ appState: { ...excalidrawAPI.getAppState() } }); } } const toolbar = document.getElementById("ea-mindmap-mobile-toolbar"); if (toolbar) toolbar.remove(); const tbStyle = document.getElementById("ea-mindmap-toolbar-style"); if (tbStyle) tbStyle.remove(); console.log(t("log_unmounted", { id: SCRIPT_ID })); }; console.log(t("log_mounted", { id: SCRIPT_ID })); } mountFeature();