diff --git a/VS Code/README.md b/VS Code/README.md index a2284da..17327ca 100644 --- a/VS Code/README.md +++ b/VS Code/README.md @@ -38,6 +38,7 @@ See screenshots at [CodeMindMap.com](https://codemindmap.com/) - Fix: focus map element on node select so keyboard shortcuts work on first click. - Feature: save diagrams as .json with pretty printing, open accepts .json and .txt. - Feature: reopen diagram automatically after window reload. + - Node completion status: press `c` on any node to cycle through In Progress (⟳) and Completed (✓) states. Status is saved with the diagram and can also be set via right-click context menu. - **v1.19** - "Code node added" status bar notification. diff --git a/VS Code/package-lock.json b/VS Code/package-lock.json index 93b8792..035636e 100644 --- a/VS Code/package-lock.json +++ b/VS Code/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-mind-map", - "version": "1.14.1", + "version": "1.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-mind-map", - "version": "1.14.1", + "version": "1.20.0", "devDependencies": { "@types/glob": "^8.1.0", "@types/mocha": "^10.0.6", diff --git a/VS Code/src/extension.ts b/VS Code/src/extension.ts index 29cb3a2..364c78a 100644 --- a/VS Code/src/extension.ts +++ b/VS Code/src/extension.ts @@ -742,6 +742,54 @@ export class CodeMindMapPanel { .hidden { display: none !important; } + /* Root node: match the same padding-to-font ratio as level-1 nodes. + Level-1 uses 8px/25px padding with 14px font (ratios 0.57v / 1.79h). + Root has 25px font, so target: 14px vertical / 45px horizontal. */ + .map-container me-root me-tpc { + padding: 14px 45px !important; + } + + /* Status indicator styles */ + /* Root (45px horizontal padding) and level-1 (25px padding) already have enough room for the icon */ + .map-container me-tpc { + position: relative; + } + /* Level-2+ nodes: always reserve icon space so text never shifts when status changes. + mind.linkDiv() is called after applying statuses to redraw lines at the new sizes. */ + .map-container me-children me-parent me-tpc { + padding-left: 20px; + box-sizing: border-box; + } + .map-container me-tpc[data-status="completed"] { + text-decoration: line-through; + } + .map-container me-tpc[data-status="completed"] > .text { + text-decoration: line-through !important; + } + .map-container me-tpc[data-status]::before { + position: absolute; + left: 2px; /* level-2+: within the 20px padding we add */ + top: 50%; + transform: translateY(-50%); + width: 14px; + text-align: center; + font-weight: bold; + } + /* Center icon in the existing left padding for root (45px) and level-1 (25px) */ + .map-container me-root me-tpc[data-status]::before { + left: 16px; /* (45px - 14px) / 2 */ + } + .map-container me-main > me-wrapper > me-parent > me-tpc[data-status]::before { + left: 6px; /* (25px - 14px) / 2 */ + } + .map-container me-tpc[data-status="in-progress"]::before { + content: '⟳'; + color: #ff9800; + } + .map-container me-tpc[data-status="completed"]::before { + content: '✓'; + color: #4caf50; + } @@ -791,13 +839,62 @@ export class CodeMindMapPanel { const vscode = acquireVsCodeApi(); let mind, data, themeManager, lastSelectedNode; - let pendingImportData = null; // stores importMindMapData payload received before mind is ready + let pendingImport = null; // stores importMindMapData payload received before mind is ready + let linkDivDebounceTimer = null; // debounce timer for the linkDiv bus event + let scheduleRafHandle = null; + let scheduleTimerHandle = null; function initMindMap() { const options = { el: '#map', allowUndo: true, toolBar: true, + contextMenu: { + extend: [ + { + name: '⟳ In Progress', + onclick: () => { + const node = mind.currentNode?.nodeObj; + if (!node) return; + node.data = node.data || {}; + node.data.status = 'in-progress'; + updateNodeStatus(node); + vscode.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); + const cm = document.querySelector('.map-container > .context-menu'); if (cm) cm.hidden = true; + } + }, + { + name: '✓ Completed', + onclick: () => { + const node = mind.currentNode?.nodeObj; + if (!node) return; + node.data = node.data || {}; + node.data.status = 'completed'; + updateNodeStatus(node); + vscode.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); + const cm = document.querySelector('.map-container > .context-menu'); if (cm) cm.hidden = true; + } + }, + { + name: '✕ Clear Status', + onclick: () => { + const node = mind.currentNode?.nodeObj; + if (!node) return; + node.data = node.data || {}; + delete node.data.status; + updateNodeStatus(node); + vscode.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); + const cm = document.querySelector('.map-container > .context-menu'); if (cm) cm.hidden = true; + } + }, + ] + }, + view: { + beforeSelect(el, node) { + mind.currentNode = node; + return true; + } + } }; const LIGHT_THEME = { @@ -949,6 +1046,28 @@ export class CodeMindMapPanel { topic: 'Space - Expand/collapse nodes', id: 'bd1bb2ac4bbab458', }, + { + topic: 'c - Cycle node status: (none) → In Progress → Completed → (none)', + id: 'bd1bb2ac4bbab460', + children: [ + { + topic: 'In Progress nodes show ⟳ in orange', + id: 'bd1bb2ac4bbab461', + }, + { + topic: 'Completed nodes show ✓ with strikethrough text', + id: 'bd1bb2ac4bbab462', + }, + { + topic: 'Right-click a node to set status directly from the context menu', + id: 'bd1bb2ac4bbab463', + }, + { + topic: 'Status is saved automatically with the diagram', + id: 'bd1bb2ac4bbab464', + }, + ], + }, ], }, ], @@ -962,11 +1081,13 @@ export class CodeMindMapPanel { mind.init(data); // Apply any import that arrived before mind was ready - if (pendingImportData !== null) { - window.importData(pendingImportData); - pendingImportData = null; + if (pendingImport !== null) { + window.importData(pendingImport); + pendingImport = null; } + scheduleApplyAllStatuses(); + // Intercept direction-change methods so autosave is triggered when the // user switches between left tree, right tree, and flower (side) views. ['initLeft', 'initRight', 'initSide'].forEach(method => { @@ -977,6 +1098,61 @@ export class CodeMindMapPanel { }; }); + // Helper function to update node visual status + function updateNodeStatus(nodeObj) { + if (!nodeObj || !nodeObj.id) return; + const nodeElement = MindElixir.E(nodeObj.id); + if (!nodeElement) return; + + const status = nodeObj.data?.status || null; + const domEl = nodeElement.getEl?.() || nodeElement; + if (!domEl) return; + + const topicEl = (() => { + if (domEl.tagName === 'ME-TPC') return domEl; + const byQuery = domEl.querySelector?.('me-tpc'); + if (byQuery) return byQuery; + const byTag = domEl.getElementsByTagName?.('me-tpc')?.[0]; + if (byTag) return byTag; + return domEl; + })(); + + if (!topicEl) return; + + if (!status) { + topicEl.removeAttribute('data-status'); + return; + } + + topicEl.setAttribute('data-status', status); + } + + function applyAllStatuses() { + const root = mind?.nodeData; + if (!root) return; + const stack = [root]; + while (stack.length > 0) { + const node = stack.pop(); + if (!node) continue; + updateNodeStatus(node); + if (Array.isArray(node.children)) { + for (const child of node.children) { + stack.push(child); + } + } + } + // linkDiv is called by MindElixir itself after layout; we must not call it here + // as that would create an infinite loop via the linkDiv bus listener + } + + function scheduleApplyAllStatuses() { + if (!mind) return; + if (scheduleRafHandle !== null) cancelAnimationFrame(scheduleRafHandle); + if (scheduleTimerHandle !== null) clearTimeout(scheduleTimerHandle); + scheduleRafHandle = requestAnimationFrame(() => { applyAllStatuses(); scheduleRafHandle = null; }); + scheduleTimerHandle = setTimeout(() => { applyAllStatuses(); scheduleTimerHandle = null; }, 50); + } + mind.bus.addListener('selectNodes', nodes => { const node = nodes.at(-1); if (!node) return; @@ -987,6 +1163,7 @@ export class CodeMindMapPanel { nodeTopic: node.topic, nodeData: node.data, }); + scheduleApplyAllStatuses(); }); mind.bus.addListener('selectNewNode', () => { @@ -994,11 +1171,20 @@ export class CodeMindMapPanel { mind.map?.focus(); }); + // Debounced linkDiv listener: MindElixir fires linkDiv after every layout pass + // (including multiple passes after refresh/changeTheme). Wait for 50ms of silence + // before applying statuses so we always run after the final DOM state. + mind.bus.addListener('linkDiv', () => { + clearTimeout(linkDivDebounceTimer); + linkDivDebounceTimer = setTimeout(applyAllStatuses, 50); + }); + mind.bus.addListener('operation', operation => { vscode.postMessage({ action: 'mindMapOperation', operationName: operation.name, }); + scheduleApplyAllStatuses(); }); document.addEventListener('click', (e) => { @@ -1013,6 +1199,22 @@ export class CodeMindMapPanel { }); } } + else if ((e.key === 'c' || e.key === 'C') && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { + e.preventDefault(); + const currentNode = mind.currentNode?.nodeObj; + if (!currentNode) return; + + currentNode.data = currentNode.data || {}; + + // Cycle through status: not-started -> in-progress -> completed -> not-started + const statuses = ['not-started', 'in-progress', 'completed']; + const currentStatus = currentNode.data.status || 'not-started'; + const currentIndex = statuses.indexOf(currentStatus); + const nextIndex = (currentIndex + 1) % statuses.length; + currentNode.data.status = statuses[nextIndex]; + + updateNodeStatus(currentNode); + } }); document.addEventListener('keydown', function(e) { @@ -1034,6 +1236,31 @@ export class CodeMindMapPanel { }); } } + else if ((e.key === 'c' || e.key === 'C') && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { + // Skip if MindElixir's inline editor is open + if (document.getElementById('input-box')) return; + e.preventDefault(); + const currentNode = mind.currentNode?.nodeObj; + if (!currentNode) return; + currentNode.data = currentNode.data || {}; + + // Cycle: (none) -> in-progress -> completed -> (none) + const statuses = [null, 'in-progress', 'completed']; + const currentStatus = currentNode.data.status || null; + const currentIndex = statuses.indexOf(currentStatus); + const next = statuses[(currentIndex + 1) % statuses.length]; + if (next === null) { + delete currentNode.data.status; + } else { + currentNode.data.status = next; + } + + // Update visual appearance + updateNodeStatus(currentNode); + + // Trigger autosave + vscode.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); + } }); } @@ -1133,12 +1360,14 @@ export class CodeMindMapPanel { mind.refresh(mindData); mind.clearHistory(); + mind.clearHistory(); const dataThemeName = getThemeName(mindData); - if (dataThemeName != '' && themeManager.contains(dataThemeName) && dataThemeName != mind.theme?.name) { mind.changeTheme(themeManager.getTheme(dataThemeName)); } + // Statuses are applied via the debounced linkDiv bus listener + // which fires after MindElixir's layout settles. return { success: true, error: '' }; @@ -1171,7 +1400,7 @@ export class CodeMindMapPanel { }; - // Add event listeners for all buttons, then initialize the mind map + // Helper function to set up UI and initialize mind map function setupUI() { // Open Dev Tools button const devBtn = document.getElementById('openDevToolsBtn'); @@ -1232,8 +1461,7 @@ export class CodeMindMapPanel { initMindMap(); } - // Handle both early and late execution: type="module" scripts are deferred, - // so DOMContentLoaded may have already fired by the time this runs. + // Initialize when DOM is ready - handle both early and late execution if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', setupUI); } else { @@ -1285,7 +1513,7 @@ export class CodeMindMapPanel { if (mind) { window.importData(message.data); } else { - pendingImportData = message.data; + pendingImport = message.data; // mind not ready yet; apply after init } break; case 'resetMindMap': diff --git a/Visual Studio/CodeMindMap/CodeMindMapHtml.cs b/Visual Studio/CodeMindMap/CodeMindMapHtml.cs index 026ead7..6385c35 100644 --- a/Visual Studio/CodeMindMap/CodeMindMapHtml.cs +++ b/Visual Studio/CodeMindMap/CodeMindMapHtml.cs @@ -29,6 +29,53 @@ public class CodeMindMapHtml margin: 0; padding: 0; } + /* Root node: match the same padding-to-font ratio as level-1 nodes. + Level-1 uses 8px/25px padding with 14px font (ratios 0.57v / 1.79h). + Root has 25px font, so target: 14px vertical / 45px horizontal. */ + .map-container me-root me-tpc { + padding: 14px 45px !important; + } + + /* Status indicator styles */ + /* Root (45px horizontal padding) and level-1 (25px padding) already have enough room for the icon */ + .map-container me-tpc { + position: relative; + } + /* Level-2+ nodes: always reserve icon space so text never shifts when status changes. */ + .map-container me-children me-parent me-tpc { + padding-left: 20px; + box-sizing: border-box; + } + .map-container me-tpc[data-status=""completed""] { + text-decoration: line-through; + } + .map-container me-tpc[data-status=""completed""] > .text { + text-decoration: line-through !important; + } + .map-container me-tpc[data-status]::before { + position: absolute; + left: 2px; /* level-2+: within the 20px padding we add */ + top: 50%; + transform: translateY(-50%); + width: 14px; + text-align: center; + font-weight: bold; + } + /* Center icon in the existing left padding for root (45px) and level-1 (25px) */ + .map-container me-root me-tpc[data-status]::before { + left: 16px; /* (45px - 14px) / 2 */ + } + .map-container me-main > me-wrapper > me-parent > me-tpc[data-status]::before { + left: 6px; /* (25px - 14px) / 2 */ + } + .map-container me-tpc[data-status=""in-progress""]::before { + content: '⟳'; + color: #ff9800; + } + .map-container me-tpc[data-status=""completed""]::before { + content: '✓'; + color: #4caf50; + } @@ -38,12 +85,61 @@ public class CodeMindMapHtml import MindElixir from ""http://codemindmap.vsext/MindElixir.js""; let mind, themeManager, lastSelectedNode; + let linkDivDebounceTimer = null; + let scheduleRafHandle = null; + let scheduleTimerHandle = null; function initMindMap() { const options = { el: '#map', allowUndo: true, toolBar: true, + contextMenu: { + extend: [ + { + name: '⟳ In Progress', + onclick: () => { + const node = mind.currentNode?.nodeObj; + if (!node) return; + node.data = node.data || {}; + node.data.status = 'in-progress'; + updateNodeStatus(node); + window.chrome.webview.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); + const cm = document.querySelector('.map-container > .context-menu'); if (cm) cm.hidden = true; + } + }, + { + name: '✓ Completed', + onclick: () => { + const node = mind.currentNode?.nodeObj; + if (!node) return; + node.data = node.data || {}; + node.data.status = 'completed'; + updateNodeStatus(node); + window.chrome.webview.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); + const cm = document.querySelector('.map-container > .context-menu'); if (cm) cm.hidden = true; + } + }, + { + name: '✕ Clear Status', + onclick: () => { + const node = mind.currentNode?.nodeObj; + if (!node) return; + node.data = node.data || {}; + delete node.data.status; + updateNodeStatus(node); + window.chrome.webview.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); + const cm = document.querySelector('.map-container > .context-menu'); if (cm) cm.hidden = true; + } + }, + ] + }, + view: { + beforeSelect(el, node) { + mind.currentNode = node; + return true; + } + } }; const LIGHT_THEME = { @@ -180,6 +276,28 @@ function initMindMap() { topic: 'Space - Expand/collapse nodes', id: 'bd1bb2ac4bbab458', }, + { + topic: 'c - Cycle node status: (none) → In Progress → Completed → (none)', + id: 'bd1bb2ac4bbab460', + children: [ + { + topic: 'In Progress nodes show ⟳ in orange', + id: 'bd1bb2ac4bbab461', + }, + { + topic: 'Completed nodes show ✓ with strikethrough text', + id: 'bd1bb2ac4bbab462', + }, + { + topic: 'Right-click a node to set status directly from the context menu', + id: 'bd1bb2ac4bbab463', + }, + { + topic: 'Status is saved automatically with the diagram', + id: 'bd1bb2ac4bbab464', + }, + ], + }, ], }, ], @@ -202,6 +320,60 @@ function initMindMap() { }; }); + scheduleApplyAllStatuses(); + // Helper function to update node visual status + function updateNodeStatus(nodeObj) { + if (!nodeObj || !nodeObj.id) return; + const nodeElement = MindElixir.E(nodeObj.id); + if (!nodeElement) return; + + const status = nodeObj.data?.status || null; + const domEl = nodeElement.getEl?.() || nodeElement; + if (!domEl) return; + + const topicEl = (() => { + if (domEl.tagName === 'ME-TPC') return domEl; + const byQuery = domEl.querySelector?.('me-tpc'); + if (byQuery) return byQuery; + const byTag = domEl.getElementsByTagName?.('me-tpc')?.[0]; + if (byTag) return byTag; + return domEl; + })(); + + if (!topicEl) return; + + if (!status) { + topicEl.removeAttribute('data-status'); + return; + } + + topicEl.setAttribute('data-status', status); + } + + function applyAllStatuses() { + const root = mind?.nodeData; + if (!root) return; + const stack = [root]; + while (stack.length > 0) { + const node = stack.pop(); + if (!node) continue; + updateNodeStatus(node); + if (Array.isArray(node.children)) { + for (const child of node.children) { + stack.push(child); + } + } + } + } + + function scheduleApplyAllStatuses() { + if (!mind) return; + if (scheduleRafHandle !== null) cancelAnimationFrame(scheduleRafHandle); + if (scheduleTimerHandle !== null) clearTimeout(scheduleTimerHandle); + scheduleRafHandle = requestAnimationFrame(() => { applyAllStatuses(); scheduleRafHandle = null; }); + scheduleTimerHandle = setTimeout(() => { applyAllStatuses(); scheduleTimerHandle = null; }, 50); + } + mind.bus.addListener('selectNodes', nodes => { const node = nodes.at(-1); if (!node) return; @@ -212,6 +384,7 @@ function initMindMap() { nodeTopic: node.topic, nodeData: node.data, }); + scheduleApplyAllStatuses(); }); mind.bus.addListener('selectNewNode', () => { @@ -219,11 +392,19 @@ function initMindMap() { mind.map?.focus(); }); - mind.bus.addListener('operation', operation => { - window.chrome.webview.postMessage({ - action: 'mindMapOperation', - operationName: operation.name, - }); + // Debounced linkDiv listener: MindElixir fires linkDiv after every layout pass. + // Wait for 50ms of silence before applying statuses so we run after the final DOM state. + mind.bus.addListener('linkDiv', () => { + clearTimeout(linkDivDebounceTimer); + linkDivDebounceTimer = setTimeout(applyAllStatuses, 50); + }); + + mind.bus.addListener('operation', operation => { + window.chrome.webview.postMessage({ + action: 'mindMapOperation', + operationName: operation.name, + }); + scheduleApplyAllStatuses(); }); document.addEventListener('click', (e) => { @@ -248,7 +429,41 @@ function initMindMap() { const targetNode = MindElixir.E(targetNodeId); if (targetNode) mind.expandNode(targetNode); } - }); + else if ((e.ctrlKey || e.metaKey) && e.key === 'c') { + const currentNodeObj = mind.currentNode?.nodeObj + if (currentNodeObj) { + window.chrome.webview.postMessage({ + action: 'nodeCopy', + nodeId: currentNodeObj.id, + nodeTopic: currentNodeObj.topic, + nodeData: currentNodeObj.data, + }); + } + } + else if ((e.key === 'c' || e.key === 'C') && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { + // Skip if MindElixir's inline editor is open + if (document.getElementById('input-box')) return; + e.preventDefault(); + const currentNode = mind.currentNode?.nodeObj; + if (!currentNode) return; + + currentNode.data = currentNode.data || {}; + + // Cycle: (none) -> in-progress -> completed -> (none) + const statuses = [null, 'in-progress', 'completed']; + const currentStatus = currentNode.data.status || null; + const currentIndex = statuses.indexOf(currentStatus); + const next = statuses[(currentIndex + 1) % statuses.length]; + if (next === null) { + delete currentNode.data.status; + } else { + currentNode.data.status = next; + } + + updateNodeStatus(currentNode); + window.chrome.webview.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); + } + }); } @@ -352,10 +567,11 @@ function getThemeName(mindElixirData) { mind.clearHistory(); const dataThemeName = getThemeName(mindData); - if (dataThemeName != '' && themeManager.contains(dataThemeName) && dataThemeName != mind.theme?.name) { mind.changeTheme(themeManager.getTheme(dataThemeName)); } + // Statuses are applied via the debounced linkDiv bus listener + // which fires after MindElixir's layout settles. return { success: true, error: """" }; } catch (e) { diff --git a/Visual Studio/CodeMindMap/MindMap/NodeData.cs b/Visual Studio/CodeMindMap/MindMap/NodeData.cs index f0c42fc..bc34dec 100644 --- a/Visual Studio/CodeMindMap/MindMap/NodeData.cs +++ b/Visual Studio/CodeMindMap/MindMap/NodeData.cs @@ -5,5 +5,6 @@ internal class NodeData public string FileName; public string FilePath; public int TopLine; + public string Status; // in-progress, completed } }