// ==UserScript==
// @name SVG Mindmap to Freemind Converter (NotebookLM - Hierarchical)
// @namespace http://tampermonkey.net/
// @version 2.4
// @description Converts NotebookLM mindmap SVG to hierarchical Freemind (.mm). Auto-expands nodes. Matches path ends to node EDGE MIDPOINTS. REQUIRES ACCURATE SELECTORS. Check console.
// @author mike868(小红书:迈克八六八)
// @match https://notebooklm.google.com/notebook/*
// @grant GM_download
// @run-at document-end
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- Configuration ---
// ====> !!! CRITICAL: YOU **MUST** INSPECT THE LIVE NOTEBOOKLM SVG AND ADJUST THESE !!! <====
const svgSelector = 'div.mindmap > svg'; // Selector for the main SVG element.
const nodeGroupSelector = 'g[transform]'; // Selector for the <g> containing all nodes/paths. Try 'g' if unsure.
const nodeSelector = 'g.node'; // Selector for individual node <g> elements.
const rectSelector = 'rect'; // Selector *within* a node <g> for the main background rectangle.
const textSelector = 'text.node-name'; // Selector *within* a node <g> for the primary text.
const textFallbackSelector = 'text'; // Fallback selector for text.
const linkSelector = 'path.link'; // Selector for connection <path> elements.
const expandCircleSelector = 'circle'; // Selector for expandable node circles.
const expandSymbolSelector = 'text.expand-symbol'; // Selector for expand/collapse symbols (> or <).
const NODE_MATCH_TOLERANCE_PX = 85; // How close (pixels) path endpoint needs to be to node *edge midpoint*. ADJUST IF NEEDED.
const EXPAND_DELAY_MS = 300; // Delay between expanding nodes (to prevent overwhelming the browser).
const MAX_EXPAND_ATTEMPTS = 3; // Maximum number of attempts to expand all nodes.
// ==========================================================================================
const buttonText = 'Convert MindMap to .mm (Hierarchy v2.4 Edge Match)';
const buttonId = 'svg-to-mm-converter-button-v2-4-hierarchical';
const expandButtonText = 'Expand All Nodes';
const expandButtonId = 'expand-all-nodes-button';
const NODE_MATCH_TOLERANCE_SQ = NODE_MATCH_TOLERANCE_PX * NODE_MATCH_TOLERANCE_PX;
console.log("SVG to MM Script v2.4 (Edge Match + Auto-Expand) Loaded. Waiting for page content...");
// --- Helper Functions ---
function escapeXml(unsafe) { /* ... same ... */ if(typeof unsafe !== 'string') return ''; return unsafe.replace(/[<>&"']/g, c => ({'<':'<','>':'>','&':'&','"':'"',"'":'''})[c]); }
function getTranslateXY(element) { /* ... same ... */ if(!element || typeof element.getAttribute !== 'function') return null; try { const transform = element.getAttribute('transform'); if(transform) { const match = transform.match(/translate\(\s*([^,\s]+)\s*,\s*([^,\s\)]+)\s*\)/); if(match) return { x: parseFloat(match[1]), y: parseFloat(match[2]) }; } } catch (e) { console.warn("Error parsing translate:", e, element); } return null; }
function parsePathEndpoints(d) { /* ... same ... */ if(!d) return null; try { const moveMatch = d.match(/M\s*(-?[\d\.]+)\s*[,\s]\s*(-?[\d\.]+)/i); const endCoordMatch = d.match(/(-?[\d\.]+)\s*[,\s]\s*(-?[\d\.]+)\s*Z?$/i); if(moveMatch && endCoordMatch) return { startX: parseFloat(moveMatch[1]), startY: parseFloat(moveMatch[2]), endX: parseFloat(endCoordMatch[1]), endY: parseFloat(endCoordMatch[2]) }; else { console.warn(`Path parse failed primary regex:`, d); /* fallback omitted for brevity but could be added back */ } } catch (e) { console.error("Path parse error:", e, d); } return null; }
function distanceSq(p1, p2) { /* ... same ... */ if (!p1 || !p2) return Infinity; const dx = p1.x - p2.x; const dy = p1.y - p2.y; return dx * dx + dy * dy; }
/**
* Finds the node whose specified edge midpoint(s) are closest to a given point.
* @param {{x: number, y: number}} point The target point (e.g., path endpoint).
* @param {Array} nodesWithEdges Array of node objects { id, leftEdgeMid?: {x, y}, rightEdgeMid?: {x, y}, ... }.
* @param {number} toleranceSq Max squared distance allowed.
* @param {Array<string>} edgeKeys Array of keys for edge midpoints to check (e.g., ['leftEdgeMid', 'rightEdgeMid']).
* @returns {object | null} The closest node object or null.
*/
function findClosestNodeByEdge(point, nodesWithEdges, toleranceSq, edgeKeys) {
let closestNode = null;
let minDistSq = Infinity;
let closestEdgeKey = null; // For debugging
if (!point || !edgeKeys || edgeKeys.length === 0) {
console.warn("findClosestNodeByEdge: Invalid point or edgeKeys.", {point, edgeKeys});
return null;
}
nodesWithEdges.forEach(node => {
edgeKeys.forEach(key => {
const edgeMidpoint = node[key];
if (edgeMidpoint) { // Check if this specific edge point exists for the node
const distSq = distanceSq(point, edgeMidpoint);
if (distSq < minDistSq) {
minDistSq = distSq;
closestNode = node;
closestEdgeKey = key; // Store which edge matched best
}
}
});
});
if (closestNode && minDistSq <= toleranceSq) {
// console.log(`-> Matched point (${point.x.toFixed(1)}, ${point.y.toFixed(1)}) to node "${closestNode.text}" via edge "${closestEdgeKey}" (Dist: ${Math.sqrt(minDistSq).toFixed(1)}px)`);
return closestNode;
} else {
// console.warn(`-> No node edge (${edgeKeys.join('/')}) found close enough to point (${point.x.toFixed(1)}, ${point.y.toFixed(1)}). ` +
// `Closest edge dist: ${Math.sqrt(minDistSq).toFixed(1)}px`);
return null;
}
}
function getNodeText(svgNodeElement) { /* ... same as v2.2 ... */ if(!svgNodeElement || typeof svgNodeElement.querySelector !== 'function') return 'Invalid Element'; let nodeText = 'Untitled'; try { let textElement = svgNodeElement.querySelector(`:scope > ${textSelector}`); if(!textElement) textElement = svgNodeElement.querySelector(`:scope > ${textFallbackSelector}`); if(textElement && textElement.textContent){ nodeText = textElement.textContent.trim(); const tspans = textElement.querySelectorAll(':scope > tspan'); if(tspans.length > 0) nodeText = Array.from(tspans).map(tspan => tspan.textContent.trim()).join(' ').trim(); nodeText = nodeText.replace(/\s+/g, ' '); } else { console.warn(`getNodeText: No text found:`, svgNodeElement); nodeText = (svgNodeElement.textContent || '').replace(/\s+/g, ' ').trim() || 'Untitled Fallback'; } } catch (e) { console.error("Error in getNodeText:", svgNodeElement, e); nodeText = "Error Reading Text"; } return nodeText || "Untitled Error"; }
/**
* Attempts to click on an SVG element using various methods
* @param {Element} element The element to click
* @param {string} description Description for logging
* @returns {boolean} Whether any click method succeeded
*/
function attemptClickOnElement(element, description) {
if (!element) return false;
let clickSuccess = false;
// Method 1: dispatchEvent with MouseEvent
try {
const clickEvent = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true
});
element.dispatchEvent(clickEvent);
console.log(`Method 1 (MouseEvent) attempted on ${description}`);
clickSuccess = true;
} catch (e) {
console.warn(`Method 1 (MouseEvent) failed on ${description}: ${e.message}`);
}
// Method 2: createEvent (older browsers)
if (!clickSuccess) {
try {
const clickEvent = document.createEvent('MouseEvents');
clickEvent.initEvent('click', true, true);
element.dispatchEvent(clickEvent);
console.log(`Method 2 (createEvent) attempted on ${description}`);
clickSuccess = true;
} catch (e) {
console.warn(`Method 2 (createEvent) failed on ${description}: ${e.message}`);
}
}
// Method 3: Trigger click via parent HTML element if available
if (!clickSuccess && element.parentElement && !(element instanceof SVGElement)) {
try {
element.parentElement.click();
console.log(`Method 3 (parent click) attempted on ${description}`);
clickSuccess = true;
} catch (e) {
console.warn(`Method 3 (parent click) failed on ${description}: ${e.message}`);
}
}
return clickSuccess;
}
/**
* Expands all collapsible nodes in the mindmap.
* @param {Element} svgElement The SVG element containing the mindmap.
* @param {Function} callback Optional callback to run after expansion is complete.
*/
function expandAllNodes(svgElement, callback) {
if (!svgElement) {
console.error("expandAllNodes: No SVG element provided.");
if (callback) callback(false);
return;
}
console.log("Starting node expansion process...");
// Find all nodes with ">" expand symbol (collapsed nodes)
const mainGroup = svgElement.querySelector(nodeGroupSelector) || svgElement;
let expandableNodes = Array.from(mainGroup.querySelectorAll(`${nodeSelector} ${expandSymbolSelector}`))
.filter(symbol => symbol.textContent === ">" || symbol.textContent.includes(">"));
if (expandableNodes.length === 0) {
console.log("No expandable nodes found.");
if (callback) callback(true);
return;
}
console.log(`Found ${expandableNodes.length} expandable nodes.`);
let expandedCount = 0;
let attemptCount = 0;
let previousCount = -1;
// Function to expand nodes with delay
function expandNodesWithDelay() {
// If no progress was made in the last attempt and we've tried enough times, stop
if (previousCount === expandableNodes.length && attemptCount >= MAX_EXPAND_ATTEMPTS) {
console.log(`Stopped expanding after ${attemptCount} attempts. ${expandedCount} nodes expanded. ${expandableNodes.length} nodes still collapsed.`);
if (callback) callback(expandedCount > 0);
return;
}
previousCount = expandableNodes.length;
attemptCount++;
// Find expandable nodes again (as DOM may have changed)
expandableNodes = Array.from(mainGroup.querySelectorAll(`${nodeSelector} ${expandSymbolSelector}`))
.filter(symbol => symbol.textContent === ">" || symbol.textContent.includes(">"));
if (expandableNodes.length === 0) {
console.log(`All nodes expanded successfully after ${attemptCount} attempts.`);
if (callback) callback(true);
return;
}
console.log(`Attempt ${attemptCount}: Found ${expandableNodes.length} nodes to expand.`);
// Get the parent node of the expand symbol and find its circle
const nodeToExpand = expandableNodes[0];
const parentNode = nodeToExpand.closest(nodeSelector);
if (parentNode) {
const nodeText = getNodeText(parentNode);
console.log(`Trying to expand node: "${nodeText}"`);
let clickSuccess = false;
// Try 1: Click on the circle element
const expandCircle = parentNode.querySelector(expandCircleSelector);
if (expandCircle) {
console.log(`Trying to click circle for node: "${nodeText}"`);
clickSuccess = attemptClickOnElement(expandCircle, `circle for "${nodeText}"`);
}
// Try 2: Click on the expand symbol text
if (!clickSuccess) {
console.log(`Trying to click expand symbol for node: "${nodeText}"`);
clickSuccess = attemptClickOnElement(nodeToExpand, `expand symbol for "${nodeText}"`);
}
// Try 3: Click on the rect element
if (!clickSuccess) {
const rect = parentNode.querySelector(rectSelector);
if (rect) {
console.log(`Trying to click rect for node: "${nodeText}"`);
clickSuccess = attemptClickOnElement(rect, `rect for "${nodeText}"`);
}
}
// Try 4: Click on the parent node itself
if (!clickSuccess) {
console.log(`Trying to click parent node for: "${nodeText}"`);
clickSuccess = attemptClickOnElement(parentNode, `parent node for "${nodeText}"`);
}
if (clickSuccess) {
expandedCount++;
} else {
console.warn(`Failed to expand node "${nodeText}" after all attempts`);
}
// Continue with next node after a delay
setTimeout(expandNodesWithDelay, EXPAND_DELAY_MS);
} else {
console.warn("Could not find parent node for expand symbol:", nodeToExpand);
setTimeout(expandNodesWithDelay, EXPAND_DELAY_MS);
}
}
// Start expanding nodes
expandNodesWithDelay();
}
function generateMmNodeXml(node, nodeMap, indent, parentNode) { /* ... same logic as v2.2, uses centerPos or pos for POSITION attr ... */
let position = '';
const nodePosForSort = node.centerPos || node.pos; // Prefer center for position attribute too
const parentPosForSort = parentNode ? (parentNode.centerPos || parentNode.pos) : null;
if (parentPosForSort && nodePosForSort) {
const positionBuffer = 10;
if (nodePosForSort.x > parentPosForSort.x + positionBuffer) position = 'right';
else if (nodePosForSort.x < parentPosForSort.x - positionBuffer) position = 'left';
}
const posAttr = position ? ` POSITION="${position}"` : '';
const nodeIdText = node.text.substring(0, 20).replace(/[^a-zA-Z0-9]/g, '_');
const nodeId = `ID_${node.id || nodeIdText || 'node'}`;
let xml = `${indent}<node TEXT="${escapeXml(node.text)}"${posAttr} ID="${nodeId}"`;
if (node.children && node.children.length > 0) {
xml += `>\n`;
const sortedChildren = node.children
.map(id => nodeMap.get(id))
.filter(Boolean)
.sort((a, b) => ((a.centerPos?.y ?? a.pos?.y ?? 0) - (b.centerPos?.y ?? b.pos?.y ?? 0))); // Sort by Y
sortedChildren.forEach(childNode => { xml += generateMmNodeXml(childNode, nodeMap, indent + '\t', node); });
xml += `${indent}</node>\n`;
} else { xml += `/>\n`; }
return xml;
}
function convertSvgToHierarchicalFreemind(svgElement) {
console.log("convertSvgToHierarchicalFreemind v2.4: Starting conversion...");
if (!svgElement || typeof svgElement.querySelector !== 'function') { /*...*/ return null; }
const mainGroup = svgElement.querySelector(nodeGroupSelector);
const containerElement = mainGroup || svgElement;
console.log("Using container:", containerElement.tagName);
const nodeElements = Array.from(containerElement.querySelectorAll(`:scope > ${nodeSelector}`));
const linkElements = Array.from(containerElement.querySelectorAll(`:scope > ${linkSelector}`));
console.log(`Found ${nodeElements.length} nodes, ${linkElements.length} links.`);
if (nodeElements.length === 0) { /*...*/ return null; }
// 1. Extract Node Info (including Edge Midpoint Calculation)
const nodeMap = new Map();
const nodesWithEdges = []; // Holds nodes with successfully calculated edge positions
let nodeCounter = 0;
nodeElements.forEach((el) => {
nodeCounter++;
const nodeId = `node_${nodeCounter}`;
const nodePos = getTranslateXY(el);
const text = getNodeText(el);
if (!text || text.startsWith('Untitled') || text.startsWith('Error') || text === 'Invalid Element') { /* skip */ return; }
let centerPos = null, leftEdgeMid = null, rightEdgeMid = null;
let nodeRectElem = null;
try {
nodeRectElem = el.querySelector(`:scope > ${rectSelector}`);
if (nodeRectElem && nodePos) {
const rx = parseFloat(nodeRectElem.getAttribute('x') || '0');
const ry = parseFloat(nodeRectElem.getAttribute('y') || '0');
const rwidth = parseFloat(nodeRectElem.getAttribute('width') || '0');
const rheight = parseFloat(nodeRectElem.getAttribute('height') || '0');
if (rwidth > 0 && rheight > 0) {
// Calculate center (still useful for sorting/position attr)
centerPos = { x: nodePos.x + rx + rwidth / 2, y: nodePos.y + ry + rheight / 2 };
// Calculate edge midpoints (absolute coords)
leftEdgeMid = { x: nodePos.x + rx, y: nodePos.y + ry + rheight / 2 };
rightEdgeMid = { x: nodePos.x + rx + rwidth, y: nodePos.y + ry + rheight / 2 };
// console.log(`Node "${text}": translate=(${nodePos.x.toFixed(0)}, ${nodePos.y.toFixed(0)}), L-Edge=(${leftEdgeMid.x.toFixed(0)}, ${leftEdgeMid.y.toFixed(0)}), R-Edge=(${rightEdgeMid.x.toFixed(0)}, ${rightEdgeMid.y.toFixed(0)})`);
} else { console.warn(`Node "${text}": Rect width/height invalid.`); }
} else if (nodePos) {
console.warn(`Node "${text}": Rect not found or node has no pos. Cannot calc edges.`);
} else { console.warn(`Node "${text}": No position.`); }
} catch(e) { console.error(`Error processing rect for node "${text}":`, e, el); }
const nodeData = {
id: nodeId, text: text, pos: nodePos,
centerPos: centerPos, leftEdgeMid: leftEdgeMid, rightEdgeMid: rightEdgeMid, // Store edge points
parentId: null, children: [],
};
nodeMap.set(nodeId, nodeData);
// Add to list for matching only if edge points were calculable
if (leftEdgeMid && rightEdgeMid) {
nodesWithEdges.push(nodeData);
}
});
console.log(`Processed ${nodeMap.size} nodes. ${nodesWithEdges.length} nodes have edge midpoints calculated for linking.`);
if (nodesWithEdges.length < 2 && nodeMap.size >= 2) { console.warn("Not enough nodes with calculable edges. Linking likely impossible."); }
// 2. Extract Links and Match Parent/Child (using node edge midpoints)
let linksEstablished = 0;
let pathsParsed = 0;
linkElements.forEach((linkEl, index) => {
const d = linkEl.getAttribute('d');
if (!d) { return; }
const endpoints = parsePathEndpoints(d);
if (endpoints) {
pathsParsed++;
const startPoint = { x: endpoints.startX, y: endpoints.startY };
const endPoint = { x: endpoints.endX, y: endpoints.endY };
// console.log(`Path ${index+1}: Start=(${startPoint.x.toFixed(1)}, ${startPoint.y.toFixed(1)}), End=(${endPoint.x.toFixed(1)}, ${endPoint.y.toFixed(1)})`);
// Try to find parent by matching startPoint to a node's edge
// Prioritize right edge, then left edge
let parentNode = findClosestNodeByEdge(startPoint, nodesWithEdges, NODE_MATCH_TOLERANCE_SQ, ['rightEdgeMid']);
if (!parentNode) {
parentNode = findClosestNodeByEdge(startPoint, nodesWithEdges, NODE_MATCH_TOLERANCE_SQ, ['leftEdgeMid']);
}
// Try to find child by matching endPoint to a node's edge
// Prioritize left edge, then right edge
let childNode = findClosestNodeByEdge(endPoint, nodesWithEdges, NODE_MATCH_TOLERANCE_SQ, ['leftEdgeMid']);
if (!childNode) {
childNode = findClosestNodeByEdge(endPoint, nodesWithEdges, NODE_MATCH_TOLERANCE_SQ, ['rightEdgeMid']);
}
if (parentNode && childNode && parentNode.id !== childNode.id) {
const childNodeData = nodeMap.get(childNode.id);
const parentNodeData = nodeMap.get(parentNode.id);
if (!childNodeData || !parentNodeData) { console.error(`Internal Map Error`); return; }
if (childNodeData.parentId === null) {
childNodeData.parentId = parentNodeData.id;
if (!parentNodeData.children) parentNodeData.children = [];
if (!parentNodeData.children.includes(childNodeData.id)){
parentNodeData.children.push(childNodeData.id);
linksEstablished++;
console.log(`Link ${linksEstablished}: "${parentNodeData.text}" -> "${childNodeData.text}" (via path ${index+1})`);
}
} else if (childNodeData.parentId !== parentNodeData.id) {
console.warn(`Child "${childNodeData.text}" already has parent. Tried assigning "${parentNodeData.text}". Path ${index+1}`);
}
} else {
// Log failure reason
let reason = "";
if (!parentNode) reason += ` Could not match path start (${startPoint.x.toFixed(0)},${startPoint.y.toFixed(0)}) to any node edge (L/R).`;
if (!childNode) reason += ` Could not match path end (${endPoint.x.toFixed(0)},${endPoint.y.toFixed(0)}) to any node edge (L/R).`;
if (parentNode && childNode && parentNode.id === childNode.id) reason += ` Path endpoints matched same node ("${parentNode.text}").`;
console.warn(`Failed link for path ${index + 1}.${reason}`);
}
} else { console.warn(`Skipping link ${index + 1} - bad 'd'`); }
});
console.log(`Parsed ${pathsParsed}/${linkElements.length} paths. Established ${linksEstablished} links via edge matching.`);
if (nodeMap.size > 1 && linksEstablished === 0 && linkElements.length > 0) {
console.error("CRITICAL: Edge linking failed. Check console warnings. Ensure rect selector is correct, try increasing tolerance drastically (e.g., 150), or SVG structure is unexpected.");
alert("Conversion Warning (v2.4): Failed to link nodes using edges. Check console (F12). Verify selectors, try higher tolerance.");
}
// 3. Find Root Node(s)
const rootNodesData = []; /* ... same logic as v2.2 ... */
nodeMap.forEach(node => { if(node.parentId === null) rootNodesData.push(node); });
console.log(`Found ${rootNodesData.length} potential root(s).`);
let actualRootNode = null; /* ... same root selection logic as v2.2 ... */
let rootText = "MindmapRoot";
if(rootNodesData.length === 0 && nodeMap.size > 0) { console.error("No root found!"); /* fallback... */ if(nodesWithEdges.length > 0) { nodesWithEdges.sort((a,b) => (a.centerPos?.x ?? a.pos?.x ?? Infinity) - (b.centerPos?.x ?? b.pos?.x ?? Infinity)); actualRootNode = nodesWithEdges[0]; console.warn(`Fallback root: "${actualRootNode?.text}"`); } else { actualRootNode = nodeMap.values().next().value; } if(!actualRootNode) { alert("Crit Err: No root"); return null; } }
else if (rootNodesData.length > 1) { console.warn("Multiple roots:", rootNodesData.map(n=>n.text)); rootNodesData.sort((a,b)=>{ const ax = a.centerPos?.x ?? a.pos?.x ?? Infinity; const bx = b.centerPos?.x ?? b.pos?.x ?? Infinity; if (ax !== bx) return ax - bx; const ay = a.centerPos?.y ?? a.pos?.y ?? Infinity; const by = b.centerPos?.y ?? b.pos?.y ?? Infinity; return ay - by; }); actualRootNode = rootNodesData[0]; console.warn(`Selected root: "${actualRootNode?.text}"`); }
else { actualRootNode = rootNodesData[0]; console.log(`Single root: "${actualRootNode?.text}"`); }
if(!actualRootNode) { console.error("No root determined."); return null; }
rootText = actualRootNode.text;
// 4. Generate Hierarchical XML
console.log(`Generating XML from root: "${rootText}"`); /* ... same as v2.2 ... */
let mindmapXmlBody = ""; try { mindmapXmlBody = generateMmNodeXml(actualRootNode, nodeMap, '\t', null); } catch (e) { console.error("XML Gen Error:", e); return null; } if (!mindmapXmlBody) { console.error("XML Empty"); return null; }
// --- Final XML ---
const freemindXml = `<map version="1.0.1">\n` + /* ... same header comments as v2.2 + version bump ... */
`<!-- Mind map converted from SVG by Userscript v2.4 (Hierarchical/Edge Match with Auto-Expand) -->\n` +
`<!-- Selectors: svg='${svgSelector}', group='${nodeGroupSelector}', node='${nodeSelector}', rect='${rectSelector}', link='${linkSelector}', text='${textSelector}' -->\n`+
`<!-- Tolerance: ${NODE_MATCH_TOLERANCE_PX}px -->\n`+
mindmapXmlBody + `</map>`;
console.log("v2.3 Conversion process completed.");
return { mmContent: freemindXml, rootText: rootText };
}
// --- Download Function --- (Identical)
function downloadMMFile(filename, content) { /* ... same ... */ console.log(`Download: ${filename}`); try { GM_download({ url: `data:application/xml;charset=utf-8,${encodeURIComponent(content)}`, name: filename, saveAs: true, onerror: (err) => { console.error("GM_download error:", err); alert(`DL fail: ${err.error?.message || 'Unknown'}`); }, onload: () => { console.log(`DL initiated: ${filename}`); } }); } catch (e) { console.error("DL Call Error:", e); alert("DL Error: Tampermonkey/Permissions?"); } }
// --- Button Creation and Event Handling ---
function createConversionButton() {
if(document.getElementById(buttonId)) return;
const button = document.createElement('button');
button.id = buttonId;
button.textContent = buttonText;
/* Style */
button.style.position = 'fixed';
button.style.bottom = '60px';
button.style.right = '20px';
button.style.zIndex = '10003'; /* Higher z */
button.style.padding = '10px 15px';
button.style.backgroundColor = '#fbbc05'; /* Yellow */
button.style.color = 'black';
button.style.border = 'none';
button.style.borderRadius = '4px';
button.style.cursor = 'pointer';
button.style.fontSize = '14px';
button.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
button.style.fontFamily = 'Roboto, Arial, sans-serif';
/* Event */
button.addEventListener('click', () => {
console.log(`Button "${buttonText}" clicked.`);
button.textContent = "Processing...";
// Use these properties instead of disabled attribute to avoid focus issues
button.style.pointerEvents = 'none';
button.style.opacity = '0.7';
button.style.cursor = 'default';
setTimeout(() => {
try {
const svg = document.querySelector(svgSelector);
if(!svg) {
throw new Error("SVG missing!");
}
console.log("SVG found:", svg);
const result = convertSvgToHierarchicalFreemind(svg);
if(result?.mmContent) {
let fn = "mindmap_hierarchy.mm";
if(result.rootText && !result.rootText.startsWith('Untitled')) {
fn = result.rootText.replace(/[<>:"/\\|?*\s\.]+/g, '_').replace(/_+/g, '_').substring(0, 100);
fn = (fn || "mindmap") + ".mm";
}
console.log(`Success. Root: "${result.rootText}". Download: ${fn}`);
downloadMMFile(fn, result.mmContent);
button.textContent = "DL Initiated";
} else {
alert("Edge Conversion failed (v2.4). Check console (F12). Verify selectors/tolerance.");
console.error("v2.4 result null/empty.");
button.textContent = "Convert Fail";
}
} catch(error) {
alert("Error during v2.4 conversion. Check console (F12).");
console.error("v2.4 click error:", error);
button.textContent = "Error!";
} finally {
setTimeout(() => {
button.textContent = buttonText;
// Re-enable the button
button.style.pointerEvents = '';
button.style.opacity = '';
button.style.cursor = '';
}, 3000);
}
}, 150);
});
document.body.appendChild(button);
console.log("v2.4 Edge Match button added.");
}
// Create a button to expand all nodes
function createExpandButton() {
if (document.getElementById(expandButtonId)) return;
const button = document.createElement('button');
button.id = expandButtonId;
button.textContent = expandButtonText;
// Style similar to conversion button but different color
button.style.position = 'fixed';
button.style.bottom = '110px'; // Position above the conversion button
button.style.right = '20px';
button.style.zIndex = '10003';
button.style.padding = '10px 15px';
button.style.backgroundColor = '#4285f4'; // Blue
button.style.color = 'white';
button.style.border = 'none';
button.style.borderRadius = '4px';
button.style.cursor = 'pointer';
button.style.fontSize = '14px';
button.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
button.style.fontFamily = 'Roboto, Arial, sans-serif';
// Event
button.addEventListener('click', () => {
console.log(`Button "${expandButtonText}" clicked.`);
button.textContent = "Expanding...";
// Use these properties instead of disabled attribute to avoid focus issues
button.style.pointerEvents = 'none';
button.style.opacity = '0.7';
button.style.cursor = 'default';
setTimeout(() => {
try {
const svg = document.querySelector(svgSelector);
if (!svg) {
throw new Error("SVG missing!");
}
expandAllNodes(svg, (success) => {
if (success) {
button.textContent = "Expanded!";
console.log("All nodes expanded successfully.");
} else {
button.textContent = "Expand Failed";
console.error("Failed to expand all nodes.");
}
setTimeout(() => {
button.textContent = expandButtonText;
// Re-enable the button
button.style.pointerEvents = '';
button.style.opacity = '';
button.style.cursor = '';
}, 2000);
});
} catch (error) {
alert("Error during node expansion. Check console (F12).");
console.error("Expand click error:", error);
button.textContent = "Error!";
setTimeout(() => {
button.textContent = expandButtonText;
// Re-enable the button
button.style.pointerEvents = '';
button.style.opacity = '';
button.style.cursor = '';
}, 2000);
}
}, 150);
});
document.body.appendChild(button);
console.log("Expand All button added.");
}
// --- Script Execution ---
let observer = null;
let buttonAdded = false;
function initObserver() {
console.log("Init Observer v2.4 for:", svgSelector);
const target = document.body;
const config = { childList: true, subtree: true };
const callback = (mutations, obs) => {
const svgExists = document.querySelector(svgSelector);
if(svgExists && !buttonAdded) {
console.log("SVG detected v2.4");
if(!document.getElementById(buttonId)) {
createConversionButton();
createExpandButton();
buttonAdded = true;
}
} else if (!svgExists && buttonAdded) {
console.log("SVG removed v2.4");
buttonAdded = false;
const btn = document.getElementById(buttonId);
if(btn) btn.remove();
const expandBtn = document.getElementById(expandButtonId);
if(expandBtn) expandBtn.remove();
}
};
observer = new MutationObserver(callback);
observer.observe(target, config);
if(document.querySelector(svgSelector) && !buttonAdded) {
console.log("SVG already present v2.4");
if(!document.getElementById(buttonId)) {
createConversionButton();
createExpandButton();
buttonAdded = true;
}
}
}
if(document.readyState === "complete" || document.readyState === "interactive") {
initObserver();
} else {
window.addEventListener("load", initObserver);
}
})();