From 2ffddf904151b74f977d132ecbb535968437a78a Mon Sep 17 00:00:00 2001 From: Danteday <36643531+Danteday@users.noreply.github.com> Date: Wed, 16 Apr 2025 02:39:38 -0800 Subject: [PATCH] Add files via upload --- README.md | 93 ++ __init__.py | 5 + js/node_notes.js | 2368 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 2466 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 js/node_notes.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac9510f --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# ComfyUI Note Manager +A powerful extension for ComfyUI that enables adding notes to many node in your workflow. Keep track of important settings, reminders, and workflow documentation directly within your ComfyUI canvas. + +## Features +- 📝 **Add/Edit/View Notes:** Easily add, modify, and view multiple timestamped notes for many node. +- 🔢 **Note Count Indicator:** Shows a clear icon with the note count on nodes that contain notes (when not collapsed). +- 💾 **Auto-Saves with Workflow:** Notes are saved directly within your workflow JSON file. +- 🎨 **Modern UI:** Clean modal windows for managing notes per node. +- 📤 **Node-Specific Import/Export:** Share or back up notes for individual nodes using JSON format. +- 🔍 **Selective Export:** Choose specific notes to include when exporting from a node. +- 📋 **Global Notes Panel:** View, search, and manage notes from *all* nodes in a dedicated, toggleable side panel. +- 🖱️ **Draggable Panel Toggle:** A floating `📋` icon lets you toggle the Global Notes Panel and can be dragged anywhere on the screen. +- ✈️ **Jump to Node:** Quickly navigate to a node in your workflow by clicking its title in the Global Notes Panel. +- 🌍 **Global Import/Export:** Manage notes for the *entire* workflow, including an intelligent import mapping feature. +- 🧩 **Broad Compatibility:** Designed to work with many types of ComfyUI nodes. +## Installation + +Manually: + +Clone this repo to ComfyUI/custom_nodes + +## Usage + +### Basic Note Management + +1. **Add a note to a node**: + * Right-click on any node and select "📝 Add Note" from the context menu. + * Enter your note in the modal window and click "Save". +2. **View notes on a node**: + * Right-click on a node with notes and select "📑 View Notes (`N`)" from the context menu (where `N` is the note count). +3. **Edit or delete notes**: + * In the notes modal, use the ✏️ button to edit a note. + * Use the 🗑️ button to delete a note. +4. **Identify nodes with notes**: + * Nodes with notes display an orange circle with the number of notes above the title (when not collapsed). + +### Global Notes Panel + +1. **Access the global notes panel**: + * Click the draggable `📋` icon on the screen to toggle the panel. + * All notes from your workflow are displayed, organized by node. +2. **Quick navigation**: + * Click on a node name in the global panel to center the canvas on that node. +3. **Manage & Search**: + * Use the search bar to filter notes across the entire workflow. + * Edit/Delete notes directly from the panel list. + * Use the global Import/Export buttons for workflow-wide note management. + +### Import and Export + +1. **Export notes**: + * **Node Specific:** Open the notes modal for a node, click "Import/Export", choose "Export Node Notes", select notes via checkboxes, and click "Export Selected". + * **Global:** Open the Global Notes Panel and click the "Export" button +2. **Import notes**: + * **Node Specific:** Open the notes modal for the target node, click "Import/Export", choose "Import Node Notes", and select the JSON file. + * **Global:** Open the Global Notes Panel, click the "Import" button, select the JSON file, and use the mapping dialog to assign notes to the correct nodes before confirming. + +## Tips and Tricks +- **Updating notes**: When adding or editing notes, or when you open a new workflow tab, you need to refresh the manager using the 🔄 button next to the note search field; otherwise, you may experience a synchronization issue. +- **Workflow Documentation**: Use notes on key nodes (like Loaders, Checkpoints, Samplers) to explain their role or settings. +- **Parameter Tracking**: Note down specific seeds, CFG scales, or prompt keywords that produced interesting results. +- **Troubleshooting**: Document errors encountered or specific settings needed to make a custom node work correctly. +- **Idea Repository**: Jot down ideas for workflow improvements or variations directly on the relevant nodes. +- **Global Overview**: Use the global panel to quickly review all annotations in a complex workflow. + +## Screenshots +![General](https://github.com/user-attachments/assets/a93a2882-b01e-44df-b2d1-88678cf58191) + +### Context menu +![menu](https://github.com/user-attachments/assets/51fa5b1e-ae8d-4fb8-86b3-f9b9637dd9cb) + +### Note Modal Window +![window](https://github.com/user-attachments/assets/5b3f7113-036b-4f11-8498-8533ee6bddc8) + +### Global Import Mapping Dialog +![import](https://github.com/user-attachments/assets/78422fdf-39b8-40c3-bcb7-63644804069a) +Here are the 5 options: +- 🔴 Node selection required – You must manually select the node to which the note should be added. +- 🟡 Title match – Matches the node's name, but not its ID. +- 🟡 ID match – Use this if you renamed the node to which you want to attach the note and are absolutely sure of your actions. +- 🟢 Full match (ID and title) – The best option for loading; it ensures a 100% match. +- ⚪ Selected manually – The node is selected manually. + +### Export Selection +![export](https://github.com/user-attachments/assets/c133553f-ff44-4879-af3f-84c0642b8f0d) + + + + + + + + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..f36bb63 --- /dev/null +++ b/__init__.py @@ -0,0 +1,5 @@ +WEB_DIRECTORY = "js" +NODE_CLASS_MAPPINGS = {} +__all__ = ["NODE_CLASS_MAPPINGS"] + +print("ComfyUI Node Notes extension loaded.") \ No newline at end of file diff --git a/js/node_notes.js b/js/node_notes.js new file mode 100644 index 0000000..692360c --- /dev/null +++ b/js/node_notes.js @@ -0,0 +1,2368 @@ +import { app } from "/scripts/app.js"; + +// Store notes data for each node +// Format: nodeNotes[nodeId] = [ { text: "note text", date: "2023-04-15 10:30" }, ... ] +const nodeNotes = {}; + +// Flag to control notes visibility +let notesVisible = true; + +// Create UI for notes modal +function createNotesUI() { + // Create modal container if it doesn't exist + if (!document.getElementById('notes-modal-container')) { + const modalContainer = document.createElement('div'); + modalContainer.id = 'notes-modal-container'; + modalContainer.className = 'notes-modal-container'; + modalContainer.style.display = 'none'; + + // Modal content + modalContainer.innerHTML = ` +
+
+

Node Notes

+ +
+
+
+
+
+ +
+ +
+ `; + + document.body.appendChild(modalContainer); + + // Add styles + const style = document.createElement('style'); + style.textContent = ` + .notes-modal-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; + } + + .notes-modal { + background-color: #222; + border-radius: 8px; + width: 500px; + max-width: 90%; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); + border: 1px solid #444; + } + + .notes-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + border-bottom: 1px solid #444; + background-color: #333; + border-radius: 8px 8px 0 0; + } + + .notes-modal-header h3 { + margin: 0; + color: #FF9800; + font-size: 18px; + } + + .notes-modal-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #aaa; + } + + .notes-modal-close:hover { + color: #FF9800; + } + + .notes-modal-body { + padding: 15px; + overflow-y: auto; + max-height: 60vh; + flex-grow: 1; + } + + .notes-modal-footer { + padding: 15px; + display: flex; + justify-content: space-between; + border-top: 1px solid #444; + background-color: #333; + border-radius: 0 0 8px 8px; + } + + .notes-modal-footer-right { + display: flex; + gap: 10px; + justify-content: flex-end; + } + + .notes-modal-btn { + padding: 8px 16px; + background-color: #444; + border: none; + border-radius: 4px; + color: white; + cursor: pointer; + transition: background-color 0.2s; + } + + .notes-modal-btn:hover { + background-color: #555; + } + + #notes-add-btn, #notes-save-btn { + background-color: #FF9800; + color: #111; + } + + #notes-add-btn:hover, #notes-save-btn:hover { + background-color: #FFA726; + } + + .notes-dropdown { + position: relative; + display: inline-block; + } + + .notes-dropdown-btn { + padding: 8px 16px; + background-color: #444; + border: none; + border-radius: 4px; + color: white; + cursor: pointer; + transition: background-color 0.2s; + } + + .notes-dropdown-btn:hover { + background-color: #555; + } + + .notes-dropdown-content { + display: none; + position: absolute; + bottom: 40px; + left: 0; + background-color: #333; + min-width: 180px; + box-shadow: 0 8px 16px rgba(0,0,0,0.3); + z-index: 1; + border-radius: 4px; + border: 1px solid #444; + } + + .notes-dropdown:hover .notes-dropdown-content { + display: block; + } + + .notes-dropdown-content button { + width: 100%; + text-align: left; + padding: 10px 15px; + background: none; + border: none; + color: white; + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s; + } + + .notes-dropdown-content button:hover { + background-color: #444; + } + + #notes-edit-textarea { + width: 100%; + min-height: 150px; + background-color: #333; + color: white; + border: 1px solid #555; + border-radius: 4px; + padding: 10px; + resize: vertical; + font-family: inherit; + font-size: 14px; + } + + .note-item { + background-color: #333; + border-radius: 4px; + padding: 10px 15px; + margin-bottom: 10px; + border-left: 3px solid #FF9800; + } + + .note-header { + display: flex; + justify-content: space-between; + margin-bottom: 5px; + font-size: 12px; + color: #aaa; + } + + .note-actions { + display: flex; + gap: 5px; + } + + .note-action-btn { + background: none; + border: none; + color: #aaa; + cursor: pointer; + font-size: 14px; + padding: 0 5px; + } + + .note-action-btn:hover { + color: #FF9800; + } + + .note-content { + white-space: pre-wrap; + color: white; + font-size: 14px; + } + + .note-item:last-child { + margin-bottom: 0; + } + `; + + document.head.appendChild(style); + + // Event listeners for the modal + setupModalEventListeners(); + + // Initialize dropdown behavior + initDropdownBehavior(); + } +} + +// Set up event listeners for modal buttons +function setupModalEventListeners() { + // Close modal when clicking close button + document.querySelector('.notes-modal-close').addEventListener('click', closeModal); + document.getElementById('notes-close-btn').addEventListener('click', closeModal); + + // Close modal when clicking outside + document.getElementById('notes-modal-container').addEventListener('click', function(e) { + if (e.target === this) { + closeModal(); + } + }); + + // Add note button + document.getElementById('notes-add-btn').addEventListener('click', function() { + showEditMode(); + }); + + // Save button + document.getElementById('notes-save-btn').addEventListener('click', saveNote); + + // Cancel button + document.getElementById('notes-cancel-btn').addEventListener('click', function() { + showViewMode(); + }); + + // Export/Import buttons + document.getElementById('notes-export-btn').addEventListener('click', function() { + exportNotes(true); // Export for current node only + }); + + document.getElementById('notes-import-btn').addEventListener('click', function() { + importNotes(true); // Import for current node only + }); + + document.getElementById('notes-export-all-btn').addEventListener('click', function() { + exportNotes(false); // Export all nodes + }); +} + +// Initialize dropdown behavior +function initDropdownBehavior() { + // Make sure the dropdown is closed when clicking outside + document.addEventListener('click', function(event) { + if (!event.target.matches('.notes-dropdown-btn') && !event.target.closest('.notes-dropdown-content')) { + const dropdowns = document.getElementsByClassName('notes-dropdown-content'); + for (let i = 0; i < dropdowns.length; i++) { + const openDropdown = dropdowns[i]; + if (openDropdown.style.display === 'block') { + openDropdown.style.display = 'none'; + } + } + } + }); + + // Toggle dropdown when clicking the button + document.querySelectorAll('.notes-dropdown-btn').forEach(btn => { + btn.addEventListener('click', function() { + const content = this.nextElementSibling; + content.style.display = content.style.display === 'block' ? 'none' : 'block'; + }); + }); +} + +// Open modal with notes for a node +function openNotesModal(node) { + createNotesUI(); // Make sure UI is created + + const modal = document.getElementById('notes-modal-container'); + const title = document.getElementById('notes-modal-title'); + const nodeTitle = node.title || `Node #${node.id}`; + + // Set title and show the modal + title.textContent = `Notes: ${nodeTitle}`; + modal.style.display = 'flex'; + + // Store reference to current node + modal.dataset.nodeId = node.id; + + // Show notes in view mode + showViewMode(); + renderNotes(node); +} + +// Close the modal +function closeModal() { + const modal = document.getElementById('notes-modal-container'); + if (modal) { + modal.style.display = 'none'; + } +} + +// Show modal in view mode +function showViewMode() { + document.getElementById('notes-view-container').style.display = 'block'; + document.getElementById('notes-edit-container').style.display = 'none'; + document.getElementById('notes-add-btn').style.display = 'inline-block'; + document.getElementById('notes-save-btn').style.display = 'none'; + document.getElementById('notes-cancel-btn').style.display = 'none'; + document.getElementById('notes-close-btn').style.display = 'inline-block'; +} + +// Show modal in edit mode +function showEditMode(noteText = '', editIndex = -1) { + document.getElementById('notes-view-container').style.display = 'none'; + document.getElementById('notes-edit-container').style.display = 'block'; + document.getElementById('notes-add-btn').style.display = 'none'; + document.getElementById('notes-save-btn').style.display = 'inline-block'; + document.getElementById('notes-cancel-btn').style.display = 'inline-block'; + document.getElementById('notes-close-btn').style.display = 'none'; + + const textarea = document.getElementById('notes-edit-textarea'); + textarea.value = noteText; + textarea.focus(); + + // Store edit index in the textarea dataset + textarea.dataset.editIndex = editIndex; +} + +// Get current node from modal +function getCurrentNode() { + const modal = document.getElementById('notes-modal-container'); + const nodeId = parseInt(modal.dataset.nodeId); + return app.graph._nodes_by_id[nodeId]; +} + +// Render all notes for a node +function renderNotes(node) { + const notesList = document.getElementById('notes-list'); + notesList.innerHTML = ''; + + if (!node.notes || node.notes.length === 0) { + notesList.innerHTML = '
No notes yet. Click "Add Note" to create one.
'; + return; + } + + // Display notes from newest to oldest + for (let i = node.notes.length - 1; i >= 0; i--) { + const note = node.notes[i]; + const noteElement = document.createElement('div'); + noteElement.className = 'note-item'; + + noteElement.innerHTML = ` +
+ ${note.date} +
+ + +
+
+
${escapeHTML(note.text)}
+ `; + + notesList.appendChild(noteElement); + } + + // Add event listeners to edit and delete buttons + notesList.querySelectorAll('.edit-note').forEach(button => { + button.addEventListener('click', function() { + const index = parseInt(this.dataset.index); + editExistingNote(index); + }); + }); + + notesList.querySelectorAll('.delete-note').forEach(button => { + button.addEventListener('click', function() { + const index = parseInt(this.dataset.index); + deleteNote(index); + }); + }); +} + +// Edit an existing note +function editExistingNote(index) { + const node = getCurrentNode(); + if (node.notes && index < node.notes.length) { + showEditMode(node.notes[index].text, index); + } +} + +// Save the current note +function saveNote() { + const node = getCurrentNode(); + const textarea = document.getElementById('notes-edit-textarea'); + const noteText = textarea.value.trim(); + const editIndex = parseInt(textarea.dataset.editIndex); + + if (noteText) { + const date = new Date(); + const formattedDate = `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; + const note = { + text: noteText, + date: editIndex >= 0 ? `${formattedDate} (edited)` : formattedDate + }; + + if (!node.notes) { + node.notes = []; + } + + if (editIndex >= 0 && editIndex < node.notes.length) { + // Edit existing note + node.notes[editIndex] = note; + } else { + // Add new note + node.notes.push(note); + } + + // Update global notes storage + const noteId = node.id.toString(); + nodeNotes[noteId] = [...node.notes]; + + // Update icon visibility + if (node.notes.length > 0) { + node.flags.hasNotes = true; + } + + // Mark graph as dirty to ensure it gets saved + if (app.graph) { + app.graph.change(); + } + + // Switch back to view mode and render updated notes + showViewMode(); + renderNotes(node); + } +} + +// Delete a note +function deleteNote(index) { + if (!confirm("Delete this note?")) return; + + const node = getCurrentNode(); + if (node.notes && index < node.notes.length) { + node.notes.splice(index, 1); + + // Update global notes storage + const noteId = node.id.toString(); + nodeNotes[noteId] = [...node.notes]; + + // Hide icon if there are no more notes + if (node.notes.length === 0) { + node.flags.hasNotes = false; + } + + // Mark graph as dirty to ensure it gets saved + if (app.graph) { + app.graph.change(); + } + + // Render updated notes + renderNotes(node); + } +} + +// Helper function to escape HTML content +function escapeHTML(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Export notes to a JSON file +function exportNotes(nodeOnly = false) { + if (nodeOnly) { + // Export selected notes for the current node + showExportSelectionModal(true); + } else { + // Export selected notes from all nodes + showExportSelectionModal(false); + } +} + +// Show modal for selecting notes to export +function showExportSelectionModal(nodeOnly = false) { + // Create a container for the export selection modal + const modalContainer = document.createElement('div'); + modalContainer.id = 'notes-export-modal-container'; + modalContainer.className = 'notes-modal-container'; + + let notesToExport = []; + + if (nodeOnly) { + // Get notes for current node only + const node = getCurrentNode(); + if (!node || !node.notes || node.notes.length === 0) { + alert("No notes to export for this node."); + return; + } + notesToExport.push({ + nodeId: node.id, + nodeTitle: node.title || `Node #${node.id}`, + notes: node.notes + }); + } else { + // Get all notes + if (Object.keys(nodeNotes).length === 0) { + alert("No notes to export."); + return; + } + + // Collect all notes with node information + for (const nodeId in nodeNotes) { + if (Array.isArray(nodeNotes[nodeId]) && nodeNotes[nodeId].length > 0) { + const node = app.graph._nodes_by_id[parseInt(nodeId)]; + notesToExport.push({ + nodeId: nodeId, + nodeTitle: node ? (node.title || `Node #${nodeId}`) : `Node #${nodeId}`, + notes: nodeNotes[nodeId] + }); + } + } + } + + // Create the modal content + modalContainer.innerHTML = ` +
+
+

Select Notes to Export

+ +
+
+
+ ${notesToExport.map((nodeData, nodeIdx) => ` +
+
+ + +
+
+ ${nodeData.notes.map((note, noteIdx) => ` +
+ +
+ `).join('')} +
+
+ `).join('')} +
+
+ +
+ `; + + document.body.appendChild(modalContainer); + + // Add additional styles for the export selection modal + const style = document.createElement('style'); + style.textContent = ` + #export-selection-container { + max-height: 60vh; + overflow-y: auto; + } + + .export-node-section { + margin-bottom: 20px; + border: 1px solid #444; + border-radius: 4px; + overflow: hidden; + } + + .export-node-header { + background-color: #333; + padding: 10px 15px; + display: flex; + justify-content: space-between; + align-items: center; + } + + .export-note-items { + padding: 0 15px; + } + + .export-note-item { + padding: 8px 0; + border-bottom: 1px solid #444; + } + + .export-note-item:last-child { + border-bottom: none; + } + + .export-checkbox-label { + display: flex; + align-items: flex-start; + cursor: pointer; + } + + .export-checkbox-label input[type="checkbox"] { + margin-top: 3px; + margin-right: 10px; + } + + .export-note-preview { + color: #ccc; + font-size: 13px; + white-space: pre-line; + } + + .toggle-selection-btn { + background-color: #444; + border: none; + color: white; + padding: 4px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; + } + + .toggle-selection-btn:hover { + background-color: #555; + } + `; + + document.head.appendChild(style); + + // Set up event listeners for the export selection modal + setupExportSelectionModalEventListeners(notesToExport, nodeOnly); +} + +function setupExportSelectionModalEventListeners(notesToExport, nodeOnly) { + const modalContainer = document.getElementById('notes-export-modal-container'); + + // Close button + modalContainer.querySelector('.notes-modal-close').addEventListener('click', function() { + modalContainer.remove(); + }); + + // Cancel button + document.getElementById('cancel-export-btn').addEventListener('click', function() { + modalContainer.remove(); + }); + + // Select All button + document.getElementById('select-all-notes-btn').addEventListener('click', function() { + modalContainer.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + checkbox.checked = true; + }); + }); + + // Deselect All button + document.getElementById('deselect-all-notes-btn').addEventListener('click', function() { + modalContainer.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + checkbox.checked = false; + }); + }); + + // Toggle All buttons for each node + modalContainer.querySelectorAll('.toggle-selection-btn').forEach(button => { + button.addEventListener('click', function() { + const nodeIdx = this.dataset.nodeIdx; + const nodeCheckbox = modalContainer.querySelector(`.export-node-checkbox[data-node-idx="${nodeIdx}"]`); + const noteCheckboxes = modalContainer.querySelectorAll(`.export-note-checkbox[data-node-idx="${nodeIdx}"]`); + + // Check if all note checkboxes are currently checked + const allChecked = Array.from(noteCheckboxes).every(cb => cb.checked); + + // Toggle all checkboxes + noteCheckboxes.forEach(checkbox => { + checkbox.checked = !allChecked; + }); + + // Update node checkbox based on note checkboxes + nodeCheckbox.checked = !allChecked && Array.from(noteCheckboxes).some(cb => cb.checked); + }); + }); + + // Node checkboxes control note checkboxes + modalContainer.querySelectorAll('.export-node-checkbox').forEach(checkbox => { + checkbox.addEventListener('change', function() { + const nodeIdx = this.dataset.nodeIdx; + const noteCheckboxes = modalContainer.querySelectorAll(`.export-note-checkbox[data-node-idx="${nodeIdx}"]`); + + noteCheckboxes.forEach(noteCheckbox => { + noteCheckbox.checked = this.checked; + }); + }); + }); + + // Note checkboxes affect node checkbox + modalContainer.querySelectorAll('.export-note-checkbox').forEach(checkbox => { + checkbox.addEventListener('change', function() { + const nodeIdx = this.dataset.nodeIdx; + const nodeCheckbox = modalContainer.querySelector(`.export-node-checkbox[data-node-idx="${nodeIdx}"]`); + const noteCheckboxes = modalContainer.querySelectorAll(`.export-note-checkbox[data-node-idx="${nodeIdx}"]`); + + // If at least one note is checked, the node checkbox should be checked + // If no notes are checked, the node checkbox should be unchecked + nodeCheckbox.checked = Array.from(noteCheckboxes).some(cb => cb.checked); + }); + }); + + // Export Selected button + document.getElementById('export-selected-btn').addEventListener('click', function() { + // Collect selected notes + let exportData = {}; + + notesToExport.forEach((nodeData, nodeIdx) => { + const nodeCheckbox = modalContainer.querySelector(`.export-node-checkbox[data-node-idx="${nodeIdx}"]`); + + if (nodeCheckbox.checked) { + const selectedNotes = []; + + nodeData.notes.forEach((note, noteIdx) => { + const noteCheckbox = modalContainer.querySelector(`.export-note-checkbox[data-node-idx="${nodeIdx}"][data-note-idx="${noteIdx}"]`); + + if (noteCheckbox && noteCheckbox.checked) { + selectedNotes.push(note); + } + }); + + if (selectedNotes.length > 0) { + exportData[nodeData.nodeId] = selectedNotes; + } + } + }); + + if (Object.keys(exportData).length === 0) { + alert("No notes selected for export."); + return; + } + + // Create a download link and trigger the download + const fileName = nodeOnly ? + `notes_node_${getCurrentNode().id}.json` : + "node_notes.json"; + + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData, null, 2)); + const downloadAnchorNode = document.createElement('a'); + downloadAnchorNode.setAttribute("href", dataStr); + downloadAnchorNode.setAttribute("download", fileName); + document.body.appendChild(downloadAnchorNode); + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + + // Close the modal + modalContainer.remove(); + }); +} + +// Import notes from a JSON file +function importNotes() { + // Create file input element + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.json'; + + fileInput.onchange = function(e) { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(event) { + try { + const importData = JSON.parse(event.target.result); + const node = getCurrentNode(); + const nodeId = node.id.toString(); + + // Check if the imported data is an array (notes for a single node) + if (Array.isArray(importData)) { + node.notes = importData; + nodeNotes[nodeId] = [...importData]; + } + // Check if the imported data is an object with node IDs + else if (typeof importData === 'object' && importData !== null) { + // Check if current node ID exists in imported data + if (importData[nodeId] && Array.isArray(importData[nodeId])) { + node.notes = [...importData[nodeId]]; + nodeNotes[nodeId] = [...importData[nodeId]]; + } + // If not, take the first node's notes + else { + const firstNodeId = Object.keys(importData)[0]; + if (firstNodeId && Array.isArray(importData[firstNodeId])) { + node.notes = [...importData[firstNodeId]]; + nodeNotes[nodeId] = [...importData[firstNodeId]]; + } else { + throw new Error("Invalid import format. Expected an array of notes or an object with node IDs."); + } + } + } else { + throw new Error("Invalid import format. Expected an array of notes or an object with node IDs."); + } + + // Update node display + if (node.notes.length > 0) { + node.flags.hasNotes = true; + } else { + node.flags.hasNotes = false; + } + + // Refresh the notes display + renderNotes(node); + + // Mark graph as dirty to ensure it gets saved + if (app.graph) { + app.graph.change(); + } + + } catch (error) { + console.error("Error importing notes:", error); + alert("Error importing notes: " + error.message); + } + }; + + reader.readAsText(file); + }; + + // Trigger file selection + fileInput.click(); +} + +// Create the draggable notes icon +function createNotesIcon() { + const style = document.createElement('style'); + style.textContent = ` + #notes-icon { + position: fixed; + left: 15px; + top: 50%; + transform: translateY(-50%); + width: 40px; + height: 40px; + background: #1a1a1a; + border-radius: 4px; + border: 1px solid #333; + color: #ddd; + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; + cursor: move; + z-index: 999; + transition: all 0.2s; + user-select: none; + } + + #notes-icon:hover { + background: #333; + color: #fff; + } + + #notes-icon.active { + color: #FF9800; + border-color: #FF9800; + } + `; + document.head.appendChild(style); + + const notesIcon = document.createElement('div'); + notesIcon.id = 'notes-icon'; + notesIcon.innerHTML = '📋'; + notesIcon.title = 'Toggle Notes Panel'; + + notesIcon.addEventListener('click', function(e) { + if (!notesIcon.isDragging) { + toggleGlobalNotesPanel(); + notesIcon.classList.toggle('active', + document.getElementById('global-notes-panel-container').style.display === 'flex'); + } + notesIcon.isDragging = false; + }); + + // Add drag functionality + let offsetX, offsetY; + let isDragging = false; + + notesIcon.addEventListener('mousedown', function(e) { + offsetX = e.clientX - notesIcon.getBoundingClientRect().left; + offsetY = e.clientY - notesIcon.getBoundingClientRect().top; + isDragging = true; + e.preventDefault(); + }); + + document.addEventListener('mousemove', function(e) { + if (isDragging) { + const x = e.clientX - offsetX; + const y = e.clientY - offsetY; + + notesIcon.style.left = `${x}px`; + notesIcon.style.top = `${y}px`; + notesIcon.style.transform = 'none'; + + notesIcon.isDragging = true; + } + }); + + document.addEventListener('mouseup', function() { + isDragging = false; + setTimeout(() => { + if (notesIcon.isDragging) { + notesIcon.isDragging = false; + } + }, 10); + }); + + document.body.appendChild(notesIcon); +} + +app.registerExtension({ + name: "NodeNotes", + + async beforeRegisterNodeDef(nodeType, nodeData) { + // Store the original methods to call later + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + const origGetComputeSize = nodeType.prototype.getComputeSize; + const origOnDrawForeground = nodeType.prototype.onDrawForeground; + const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + const origOnSerialize = nodeType.prototype.onSerialize; + const origOnConfigure = nodeType.prototype.onConfigure; + const origCollapse = nodeType.prototype.collapse; + const origOnResize = nodeType.prototype.onResize; + + // Add notes array to the node + nodeType.prototype.notes = []; + + // Override the onNodeCreated function + nodeType.prototype.onNodeCreated = function() { + if (origOnNodeCreated) { + origOnNodeCreated.apply(this, arguments); + } + + // Initialize note flags + if (!this.flags) { + this.flags = {}; + } + + // Initialize empty notes array + this.notes = []; + }; + + // Override onSerialize to save notes with the node + nodeType.prototype.onSerialize = function(o) { + if (origOnSerialize) { + origOnSerialize.apply(this, arguments); + } + + // Save notes in the node's serialized data + if (this.notes && this.notes.length > 0) { + o.notes = this.notes; + } + }; + + // Override onConfigure to load notes when a node is configured + nodeType.prototype.onConfigure = function(o) { + if (origOnConfigure) { + origOnConfigure.apply(this, arguments); + } + + // Load notes from the node's configuration data + if (o.notes && Array.isArray(o.notes)) { + this.notes = o.notes; + + // Update global notes storage + const noteId = this.id.toString(); + nodeNotes[noteId] = [...this.notes]; + + // Mark node as having notes + if (this.notes.length > 0) { + this.flags.hasNotes = true; + app.graph.setDirtyCanvas(true); + } + } + }; + + // Override getExtraMenuOptions to add our menu items + nodeType.prototype.getExtraMenuOptions = function(_, options) { + // Call original method if it exists + if (origGetExtraMenuOptions) { + origGetExtraMenuOptions.apply(this, arguments); + } + + // Add a separator + options.push(null); + + // Check if node has notes + if (this.notes && this.notes.length > 0) { + // Add view notes entry + options.push({ + content: `📑 View Notes (${this.notes.length})`, + callback: () => { + openNotesModal(this); + } + }); + } else { + // Add add note entry + options.push({ + content: "📝 Add Note", + callback: () => { + openNotesModal(this); + } + }); + } + + return options; + }; + + // Override getComputeSize to make room for the note icon + nodeType.prototype.getComputeSize = function() { + let size = origGetComputeSize ? origGetComputeSize.apply(this, arguments) : [200, 100]; + + // Only add extra space if we have notes + if (this.flags && this.flags.hasNotes) { + size[0] += 20; // Add a bit of width for our icon + } + + return size; + }; + + // Override onDrawForeground to draw the note icon + nodeType.prototype.onDrawForeground = function(ctx) { + if (origOnDrawForeground) { + origOnDrawForeground.apply(this, arguments); + } + + if (this.flags && this.flags.hasNotes && this.notes && this.notes.length > 0 && !this.flags.collapsed) { + ctx.save(); + + const noteIconSize = 16; + const x = this.size[0] - noteIconSize - 5; + const y = -noteIconSize - 4; + + // Draw a small info circle + ctx.fillStyle = "#FF9800"; + ctx.beginPath(); + ctx.arc(x + noteIconSize/2, y + noteIconSize/2, noteIconSize/2, 0, Math.PI * 2); + ctx.fill(); + + // Draw a number indicating how many notes + ctx.fillStyle = "white"; + ctx.font = "bold 10px Arial"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + const noteCount = Math.min(this.notes.length, 9); + const noteCountText = this.notes.length > 9 ? "9+" : noteCount.toString(); + ctx.fillText(noteCountText, x + noteIconSize/2, y + noteIconSize/2); + + ctx.restore(); + } + }; + + // Override the collapse method to update canvas when collapsing + nodeType.prototype.collapse = function(collapse) { + if (origCollapse) { + origCollapse.apply(this, arguments); + } else { + if (collapse === undefined) { + collapse = !this.flags.collapsed; + } + this.flags.collapsed = collapse; + } + + if (this.graph) { + this.graph.setDirtyCanvas(true, true); + } + }; + + // Override the onResize method to update icon position when resizing + nodeType.prototype.onResize = function(size) { + if (origOnResize) { + origOnResize.apply(this, arguments); + } + + if (this.graph && this.flags && this.flags.hasNotes) { + this.graph.setDirtyCanvas(true, true); + } + }; + }, + + async setup() { + // Create the UI early + createNotesUI(); + + // Create the draggable notes icon + createNotesIcon(); + + // Hook into the graph serialization process to save notes + const origGraphToJSON = LiteGraph.LGraph.prototype.toJSON; + + LiteGraph.LGraph.prototype.toJSON = function() { + const graphData = origGraphToJSON.apply(this, arguments); + + // Make sure there's an 'extra' field to store our data + if (!graphData.extra) { + graphData.extra = {}; + } + + graphData.extra.nodeNotes = JSON.parse(JSON.stringify(nodeNotes)); + + return graphData; + }; + + // Override graph load to restore notes + const origLoadGraphData = app.loadGraphData; + app.loadGraphData = function(graphData) { + const result = origLoadGraphData.apply(this, arguments); + + // Load notes from graph extra data + if (graphData && graphData.extra && graphData.extra.nodeNotes) { + Object.assign(nodeNotes, JSON.parse(JSON.stringify(graphData.extra.nodeNotes))); + } + + return result; + }; + + // Explicitly register the extension with ComfyUI workflow serialization + if (app.registerExtension) { + app.registerExtension({ + name: "NodeNotes.Serialization", + + beforeRegisterNodeDef(nodeType) { + // If the node already has our extension loaded, don't add it again + if (nodeType.prototype.hasOwnProperty("notes")) { + return; + } + + // Initialize empty notes array + nodeType.prototype.notes = []; + }, + + // Add properties to save in workflow + getNodeProperties(node) { + // If the node has notes, add them to saved properties + if (node.notes && node.notes.length > 0) { + return { notes: node.notes }; + } + return {}; + }, + + // When loading properties from workflow + loadedGraphNode(node, properties) { + // If there are notes in the saved properties, restore them + if (properties.notes && Array.isArray(properties.notes)) { + node.notes = properties.notes; + + // Store in global collection + const noteId = node.id.toString(); + nodeNotes[noteId] = [...node.notes]; + + // Show note icon if there are notes + if (node.notes.length > 0) { + if (!node.flags) node.flags = {}; + node.flags.hasNotes = true; + } + } + } + }); + } + + console.log("Node Notes extension loaded"); + } +}); + +// Create UI for the global notes panel +function createGlobalNotesPanel() { + if (!document.getElementById('global-notes-panel-container')) { + const panelContainer = document.createElement('div'); + panelContainer.id = 'global-notes-panel-container'; + panelContainer.className = 'global-notes-panel-container'; + panelContainer.style.display = 'none'; + + // Panel content + panelContainer.innerHTML = ` +
+
+
+ + + +
+
+
+
+
+ +
+ `; + + document.body.appendChild(panelContainer); + + // Add styles + const style = document.createElement('style'); + style.textContent = ` + .global-notes-panel-container { + position: fixed; + top: 0; + right: 0; + width: 350px; + height: 100%; + background-color: rgba(0, 0, 0, 0.3); + z-index: 9000; + display: flex; + justify-content: flex-end; + } + + .global-notes-panel { + width: 100%; + height: 100%; + background-color: #222; + border-left: 1px solid #444; + display: flex; + flex-direction: column; + } + + .global-notes-panel-header { + padding: 15px; + background-color: #333; + border-bottom: 1px solid #444; + display: flex; + justify-content: space-between; + align-items: center; + } + + .global-notes-panel-controls { + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; + } + + #global-notes-search { + padding: 5px 10px; + background-color: #444; + border: 1px solid #555; + border-radius: 4px; + color: white; + flex-grow: 1; + margin-right: 10px; + } + + #global-notes-refresh-btn { + background: none; + border: none; + font-size: 18px; + cursor: pointer; + color: #aaa; + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; + } + + #global-notes-refresh-btn:hover { + color: #FF9800; + } + + .global-notes-panel-footer { + padding: 15px; + background-color: #333; + border-top: 1px solid #444; + display: flex; + gap: 10px; + } + + #global-notes-import-all-btn, #global-notes-export-btn { + padding: 8px 16px; + background-color: #FF9800; + color: #111; + border: none; + border-radius: 4px; + cursor: pointer; + flex-grow: 1; + } + + #global-notes-import-all-btn:hover, #global-notes-export-btn:hover { + background-color: #FFA726; + } + + #global-notes-close-btn { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #aaa; + display: flex; + align-items: center; + justify-content: center; + } + + #global-notes-close-btn:hover { + color: #FF9800; + } + + .global-notes-panel-body { + flex-grow: 1; + overflow-y: auto; + padding: 15px; + } + + .global-node-notes-group { + margin-bottom: 20px; + border: 1px solid #444; + border-radius: 4px; + overflow: hidden; + } + + .global-node-header { + background-color: #333; + padding: 10px 15px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #444; + } + + .global-node-header.collapsed .collapse-indicator { + transform: rotate(-90deg); + } + + .collapse-indicator { + display: inline-block; + margin-right: 5px; + transition: transform 0.2s ease; + color: #FF9800; + font-size: 10px; + cursor: pointer; + } + + .global-node-title-text { + cursor: pointer; + } + + .global-node-notes-count { + cursor: pointer; + } + + .global-node-notes { + padding: 10px 15px; + max-height: 300px; + overflow-y: auto; + } + + .global-note-item { + padding: 8px 0; + border-bottom: 1px solid #444; + } + + .global-note-item:last-child { + border-bottom: none; + } + + .global-note-header { + display: flex; + justify-content: space-between; + margin-bottom: 5px; + font-size: 12px; + color: #aaa; + } + + .global-note-content { + color: white; + font-size: 14px; + white-space: pre-wrap; + max-height: 100px; + overflow-y: auto; + } + + .global-note-actions { + display: flex; + gap: 5px; + margin-top: 5px; + justify-content: flex-end; + } + + .global-note-btn { + padding: 3px 8px; + background-color: #444; + border: none; + border-radius: 3px; + color: white; + cursor: pointer; + font-size: 12px; + } + + .global-note-btn:hover { + background-color: #555; + } + + .no-notes-message { + color: #aaa; + text-align: center; + padding: 20px; + } + `; + document.head.appendChild(style); + + // Setup event listeners + setupGlobalNotesPanelEventListeners(); + } +} + +// Setup event listeners for global notes panel +function setupGlobalNotesPanelEventListeners() { + const panelContainer = document.getElementById('global-notes-panel-container'); + + // Close button + document.getElementById('global-notes-close-btn').addEventListener('click', function() { + panelContainer.style.display = 'none'; + }); + + // Refresh button + document.getElementById('global-notes-refresh-btn').addEventListener('click', function() { + refreshGlobalNotes(); + }); + + // Export button + document.getElementById('global-notes-export-btn').addEventListener('click', function() { + exportAllNotes(); + }); + + // Import button + document.getElementById('global-notes-import-all-btn').addEventListener('click', function() { + importAllNotes(); + }); + + // Search functionality + document.getElementById('global-notes-search').addEventListener('input', function() { + const searchTerm = this.value.toLowerCase(); + filterGlobalNotes(searchTerm); + }); +} + +// Filter global notes based on search term +function filterGlobalNotes(searchTerm) { + const nodeGroups = document.querySelectorAll('.global-node-notes-group'); + + nodeGroups.forEach(group => { + const notes = group.querySelectorAll('.global-note-item'); + let hasVisibleNotes = false; + + notes.forEach(note => { + const content = note.querySelector('.global-note-content').textContent.toLowerCase(); + const date = note.querySelector('.global-note-date').textContent.toLowerCase(); + + if (content.includes(searchTerm) || date.includes(searchTerm)) { + note.style.display = 'block'; + hasVisibleNotes = true; + } else { + note.style.display = 'none'; + } + }); + + // Show or hide the node group based on whether it has any visible notes + group.style.display = hasVisibleNotes ? 'block' : 'none'; + }); +} + +// Render all notes for a node in global panel +function renderGlobalNotes() { + const notesList = document.getElementById('global-notes-list'); + notesList.innerHTML = ''; + + // Check if we have any notes + const hasNotes = Object.keys(nodeNotes).some(nodeId => + Array.isArray(nodeNotes[nodeId]) && nodeNotes[nodeId].length > 0 + ); + + if (!hasNotes) { + notesList.innerHTML = '
No notes found in this workflow
'; + return; + } + + // Group notes by node + for (const nodeId in nodeNotes) { + if (Array.isArray(nodeNotes[nodeId]) && nodeNotes[nodeId].length > 0) { + const node = app.graph._nodes_by_id[parseInt(nodeId)]; + const nodeTitle = node ? (node.title || `Node #${nodeId}`) : `Node #${nodeId}`; + + const nodeGroup = document.createElement('div'); + nodeGroup.className = 'global-node-notes-group'; + nodeGroup.dataset.nodeId = nodeId; + + // Check if this node group was previously collapsed + const storageKey = `node_notes_collapsed_${nodeId}`; + const isCollapsed = localStorage.getItem(storageKey) === 'true'; + const collapsedClass = isCollapsed ? 'collapsed' : ''; + + nodeGroup.innerHTML = ` +
+
+ ▼ + ${escapeHTML(nodeTitle)} + ${nodeNotes[nodeId].length} +
+
+
+ ${nodeNotes[nodeId].map((note, index) => ` +
+
+ ${note.date} +
+
${escapeHTML(note.text)}
+
+ + +
+
+ `).join('')} +
+ `; + + notesList.appendChild(nodeGroup); + } + } + + // Add event listeners to the node headers to focus the node and toggle collapse + notesList.querySelectorAll('.global-node-header').forEach(header => { + header.addEventListener('click', function(e) { + const nodeId = parseInt(this.parentElement.dataset.nodeId); + const node = app.graph._nodes_by_id[nodeId]; + + if (e.target.closest('.collapse-indicator')) { + const nodeGroup = this.parentElement; + const notesContainer = nodeGroup.querySelector('.global-node-notes'); + const isCollapsed = this.classList.toggle('collapsed'); + + const storageKey = `node_notes_collapsed_${nodeId}`; + localStorage.setItem(storageKey, isCollapsed); + + if (isCollapsed) { + notesContainer.style.display = 'none'; + } else { + notesContainer.style.display = 'block'; + } + + e.stopPropagation(); + } + else if (e.target.closest('.global-node-title-text') || e.target.closest('.global-node-notes-count')) { + if (node) { + app.canvas.centerOnNode(node); + } + } + }); + }); + + // Add event listeners for edit/delete buttons + notesList.querySelectorAll('.global-edit-note').forEach(button => { + button.addEventListener('click', function(e) { + e.stopPropagation(); + const nodeId = parseInt(this.dataset.nodeId); + const noteIndex = parseInt(this.dataset.noteIndex); + const node = app.graph._nodes_by_id[nodeId]; + + if (node) { + // Open the notes modal in edit mode for this specific note + openNotesModal(node); + setTimeout(() => { + editExistingNote(noteIndex); + }, 100); + } + }); + }); + + notesList.querySelectorAll('.global-delete-note').forEach(button => { + button.addEventListener('click', function(e) { + e.stopPropagation(); + const nodeId = parseInt(this.dataset.nodeId); + const noteIndex = parseInt(this.dataset.noteIndex); + const node = app.graph._nodes_by_id[nodeId]; + + if (node && confirm("Delete this note?")) { + if (node.notes && noteIndex < node.notes.length) { + node.notes.splice(noteIndex, 1); + + // Update global notes storage + nodeNotes[nodeId] = [...node.notes]; + + if (node.notes.length === 0) { + node.flags.hasNotes = false; + } + + // Mark graph as dirty to ensure it gets saved + if (app.graph) { + app.graph.change(); + } + + // Refresh the global notes panel + renderGlobalNotes(); + } + } + }); + }); +} + +// Open global notes panel and populate it with all notes +function openGlobalNotesPanel() { + createGlobalNotesPanel(); + + const panelContainer = document.getElementById('global-notes-panel-container'); + panelContainer.style.display = 'flex'; + + renderGlobalNotes(); +} + +// Function to update the notes list in the panel +function refreshGlobalNotes() { + Object.keys(nodeNotes).forEach(key => delete nodeNotes[key]); + + const nodes = app.graph._nodes; + if (nodes && nodes.length > 0) { + nodes.forEach(node => { + if (node.notes && node.notes.length > 0) { + nodeNotes[node.id.toString()] = [...node.notes]; + } + }); + } + + if (document.getElementById('global-notes-panel-container').style.display === 'flex') { + renderGlobalNotes(); + } +} + +// Export all notes +function exportAllNotes() { + const notesToExport = {}; + + for (const nodeId in nodeNotes) { + if (Array.isArray(nodeNotes[nodeId]) && nodeNotes[nodeId].length > 0) { + notesToExport[nodeId] = [...nodeNotes[nodeId]]; + } + } + + notesToExport._nodeTitles = {}; + app.graph._nodes.forEach(node => { + if (node.title) { + notesToExport._nodeTitles[node.id.toString()] = node.title; + } + }); + + if (Object.keys(notesToExport).length <= 1) { // if only _nodeTitles exist + alert("No notes to export."); + return; + } + + const fileName = "node_notes.json"; + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(notesToExport, null, 2)); + const downloadAnchorNode = document.createElement('a'); + downloadAnchorNode.setAttribute("href", dataStr); + downloadAnchorNode.setAttribute("download", fileName); + document.body.appendChild(downloadAnchorNode); + downloadAnchorNode.click(); + downloadAnchorNode.remove(); +} + +// Temporarily highlight a node +function highlightNode(node) { + app.canvas.centerOnNode(node); +} + +// Toggle global notes panel +function toggleGlobalNotesPanel() { + const panelContainer = document.getElementById('global-notes-panel-container'); + + if (panelContainer && panelContainer.style.display === 'flex') { + panelContainer.style.display = 'none'; + } else { + openGlobalNotesPanel(); + } +} + +// Import notes through the panel +function importAllNotes() { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.json'; + + fileInput.onchange = function(e) { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(event) { + try { + const importData = JSON.parse(event.target.result); + + if (typeof importData === 'object' && importData !== null) { + const importedNodeTitles = {}; + if (importData._nodeTitles) { + Object.assign(importedNodeTitles, importData._nodeTitles); + delete importData._nodeTitles; + } + + const validNodes = Object.keys(importData).filter(nodeId => + Array.isArray(importData[nodeId]) && importData[nodeId].length > 0 + ); + + if (validNodes.length === 0) { + alert("No notes to import in the selected file."); + return; + } + + const importMapping = prepareImportMapping(importData, importedNodeTitles); + showImportMappingDialog(importMapping); + } else { + throw new Error("Invalid import format. Expected an object with node IDs and note arrays."); + } + } catch (error) { + console.error("Error importing notes:", error); + alert("Error importing notes: " + error.message); + } + }; + + reader.readAsText(file); + }; + + fileInput.click(); +} + +// Prepare data for import dialog +function prepareImportMapping(importData, importedNodeTitles) { + const existingNodes = {}; + const existingNodesByTitle = {}; + + app.graph._nodes.forEach(node => { + const nodeId = node.id.toString(); + const nodeTitle = node.title || `Node #${nodeId}`; + const nodeType = node.type; + + existingNodes[nodeId] = { + id: nodeId, + title: nodeTitle, + type: nodeType, + node: node + }; + + if (node.title) { + if (!existingNodesByTitle[node.title]) { + existingNodesByTitle[node.title] = []; + } + existingNodesByTitle[node.title].push(existingNodes[nodeId]); + } + }); + + const importMapping = []; + + for (const sourceNodeId in importData) { + const notes = importData[sourceNodeId]; + if (!Array.isArray(notes) || notes.length === 0) continue; + + let sourceNodeTitle = ''; + + if (importedNodeTitles && importedNodeTitles[sourceNodeId]) { + sourceNodeTitle = importedNodeTitles[sourceNodeId]; + } + else if (notes[0] && notes[0].nodeTitle) { + sourceNodeTitle = notes[0].nodeTitle; + } + + let suggestedTarget = null; + let matchReason = ''; + + // Check for matching options + if (existingNodes[sourceNodeId] && + (!sourceNodeTitle || existingNodes[sourceNodeId].title === sourceNodeTitle)) { + suggestedTarget = existingNodes[sourceNodeId]; + matchReason = 'Full match (ID and title)'; + } + else if (existingNodes[sourceNodeId]) { + suggestedTarget = existingNodes[sourceNodeId]; + matchReason = 'ID match'; + } + else if (sourceNodeTitle && existingNodesByTitle[sourceNodeTitle]) { + if (existingNodesByTitle[sourceNodeTitle].length === 1) { + suggestedTarget = existingNodesByTitle[sourceNodeTitle][0]; + matchReason = 'Title match'; + } + } + + importMapping.push({ + sourceNodeId: sourceNodeId, + sourceNodeTitle: sourceNodeTitle || `Node #${sourceNodeId}`, + notes: notes, + notesCount: notes.length, + previewText: notes[0].text.substring(0, 100) + (notes[0].text.length > 100 ? '...' : ''), + suggestedTarget: suggestedTarget, + matchReason: matchReason, + targetNode: suggestedTarget ? suggestedTarget.node : null + }); + } + + return { + mappings: importMapping, + existingNodes: existingNodes, + existingNodesByTitle: existingNodesByTitle + }; +} + +// Show dialog for selecting target nodes for all notes +function showImportMappingDialog(importMapping) { + const modalContainer = document.createElement('div'); + modalContainer.id = 'notes-import-mapping-modal'; + modalContainer.className = 'notes-modal-container'; + modalContainer.style.display = 'flex'; + + const existingNodes = Object.values(importMapping.existingNodes); + const nodesByType = {}; + + existingNodes.forEach(node => { + if (!nodesByType[node.type]) { + nodesByType[node.type] = []; + } + nodesByType[node.type].push(node); + }); + + const sortedTypes = Object.keys(nodesByType).sort(); + sortedTypes.forEach(type => { + nodesByType[type].sort((a, b) => a.title.localeCompare(b.title)); + }); + + const allNodes = existingNodes.map(node => ({ + id: node.id, + title: node.title, + type: node.type, + searchText: `${node.title.toLowerCase()} ${node.type.toLowerCase()} ${node.id}` + })); + + modalContainer.innerHTML = ` +
+
+

Select Notes to Import

+ +
+
+
+

Review and confirm note assignments for each node:

+
+ + +
+
+
+ ${importMapping.mappings.map((mapping, index) => { + const isAutoMatched = mapping.suggestedTarget !== null; + const matchClass = isAutoMatched ? 'has-match' : 'no-match'; + const matchIcon = isAutoMatched ? '✓' : '❓'; + + return ` +
+
+ +
+
+
+ ${mapping.sourceNodeTitle} + (${mapping.notesCount} notes) +
${escapeHTML(mapping.previewText)}
+
+
+ ${matchIcon} + → +
+
+ + ${isAutoMatched ? + `
${mapping.matchReason}
` : + '
Node selection required
'} +
+
+
+ `; + }).join('')} +
+
+ +
+ `; + + document.body.appendChild(modalContainer); + + // Add styles + const style = document.createElement('style'); + style.textContent = ` + #import-mappings-container { + max-height: 60vh; + overflow-y: auto; + } + + .import-instructions { + margin-bottom: 15px; + display: flex; + justify-content: space-between; + align-items: center; + } + + .import-select-all-container { + display: flex; + align-items: center; + gap: 15px; + } + + .small-btn { + padding: 4px 8px; + background-color: #555; + border: none; + border-radius: 3px; + color: white; + cursor: pointer; + font-size: 12px; + } + + .small-btn:hover { + background-color: #666; + } + + .import-mapping-item { + margin-bottom: 15px; + padding: 12px; + border: 1px solid #444; + border-radius: 4px; + background-color: #333; + display: flex; + gap: 10px; + } + + .import-mapping-item.has-match { + border-left: 3px solid #4CAF50; + } + + .import-mapping-item.no-match { + border-left: 3px solid #FFC107; + } + + .import-mapping-checkbox { + display: flex; + align-items: center; + padding-top: 10px; + } + + .import-mapping-content { + flex-grow: 1; + display: flex; + align-items: center; + gap: 10px; + } + + .import-mapping-source { + flex: 1; + } + + .import-mapping-arrow { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 5px; + } + + .match-icon { + font-size: 14px; + margin-bottom: 5px; + } + + .arrow { + font-size: 18px; + color: #FF9800; + } + + .import-mapping-target { + flex: 1; + } + + .import-mapping-preview { + color: #aaa; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 5px; + } + + .select-with-search { + position: relative; + margin-bottom: 5px; + } + + .search-container { + margin-bottom: 5px; + } + + .node-search-input { + width: 100%; + padding: 6px; + background-color: #333; + border: 1px solid #555; + border-radius: 4px; + color: white; + font-size: 13px; + } + + .target-node-select { + width: 100%; + padding: 8px; + background-color: #444; + border: 1px solid #555; + border-radius: 4px; + color: white; + } + + .match-reason { + font-size: 12px; + color: #4CAF50; + } + + .match-reason.no-match { + color: #FFC107; + } + + .notes-count { + color: #FF9800; + font-size: 13px; + margin-left: 5px; + } + + .option-highlighted { + background-color: #FF9800; + color: #000; + } + `; + document.head.appendChild(style); + + // Event listeners + document.querySelector('#notes-import-mapping-modal .notes-modal-close').addEventListener('click', function() { + modalContainer.remove(); + }); + + document.getElementById('cancel-import-mapping-btn').addEventListener('click', function() { + modalContainer.remove(); + }); + + const selectAllCheckbox = document.getElementById('select-all-checkbox'); + selectAllCheckbox.addEventListener('change', function() { + const isChecked = this.checked; + document.querySelectorAll('.mapping-checkbox').forEach(checkbox => { + checkbox.checked = isChecked; + }); + updateImportButtonLabel(); + }); + + document.getElementById('apply-suggested-btn').addEventListener('click', function() { + importMapping.mappings.forEach((mapping, index) => { + const select = document.querySelector(`.target-node-select[data-index="${index}"]`); + const checkbox = document.querySelector(`.mapping-checkbox[data-index="${index}"]`); + + if (mapping.suggestedTarget) { + select.value = mapping.suggestedTarget.id; + checkbox.checked = true; + + const mappingItem = document.querySelector(`.import-mapping-item[data-index="${index}"]`); + mappingItem.classList.add('has-match'); + mappingItem.classList.remove('no-match'); + + const matchIcon = mappingItem.querySelector('.match-icon'); + matchIcon.textContent = '✓'; + matchIcon.title = mapping.matchReason; + + const matchReason = mappingItem.querySelector('.match-reason'); + matchReason.textContent = mapping.matchReason; + matchReason.classList.remove('no-match'); + } + }); + + updateImportButtonLabel(); + }); + + // Search functionality + document.querySelectorAll('.node-search-input').forEach(input => { + input.addEventListener('input', function() { + const index = this.dataset.index; + const select = document.querySelector(`.target-node-select[data-index="${index}"]`); + const searchTerm = this.value.toLowerCase().trim(); + + if (searchTerm === '') { + resetSelectOptions(select); + } else { + filterSelectOptions(select, searchTerm, allNodes); + } + }); + }); + + function resetSelectOptions(select) { + const optgroups = select.querySelectorAll('optgroup'); + + optgroups.forEach(group => { + group.style.display = ''; + const options = group.querySelectorAll('option'); + options.forEach(option => { + option.style.display = ''; + option.classList.remove('option-highlighted'); + }); + }); + } + + function filterSelectOptions(select, searchTerm, allNodes) { + const optgroups = select.querySelectorAll('optgroup'); + + // Hide all options first + optgroups.forEach(group => { + group.style.display = 'none'; + const options = group.querySelectorAll('option'); + options.forEach(option => { + option.style.display = 'none'; + option.classList.remove('option-highlighted'); + }); + }); + + // Find matching nodes + const matchingNodes = allNodes.filter(node => + node.searchText.includes(searchTerm) + ); + + // Show matching options + matchingNodes.forEach(node => { + const option = select.querySelector(`option[value="${node.id}"]`); + if (option) { + option.style.display = ''; + if (node.title.toLowerCase().includes(searchTerm)) { + option.classList.add('option-highlighted'); + } + + const group = option.closest('optgroup'); + if (group) { + group.style.display = ''; + } + } + }); + + // Auto-select single match + if (matchingNodes.length === 1) { + select.value = matchingNodes[0].id; + + const event = new Event('change'); + select.dispatchEvent(event); + } + } + + // Change handlers for dropdowns + document.querySelectorAll('.target-node-select').forEach(select => { + select.addEventListener('change', function() { + const index = this.dataset.index; + const mappingItem = document.querySelector(`.import-mapping-item[data-index="${index}"]`); + const checkbox = document.querySelector(`.mapping-checkbox[data-index="${index}"]`); + const matchIcon = mappingItem.querySelector('.match-icon'); + const matchReason = mappingItem.querySelector('.match-reason'); + + if (this.value) { + mappingItem.classList.add('has-match'); + mappingItem.classList.remove('no-match'); + matchIcon.textContent = '✓'; + matchIcon.title = 'Selected manually'; + matchReason.textContent = 'Selected manually'; + matchReason.classList.remove('no-match'); + checkbox.checked = true; + } else { + mappingItem.classList.remove('has-match'); + mappingItem.classList.add('no-match'); + matchIcon.textContent = '❓'; + matchIcon.title = 'No automatic match'; + matchReason.textContent = 'Node selection required'; + matchReason.classList.add('no-match'); + checkbox.checked = false; + } + + updateImportButtonLabel(); + }); + }); + + // Checkbox handlers + document.querySelectorAll('.mapping-checkbox').forEach(checkbox => { + checkbox.addEventListener('change', function() { + updateImportButtonLabel(); + + const allChecked = Array.from(document.querySelectorAll('.mapping-checkbox')).every(cb => cb.checked); + selectAllCheckbox.checked = allChecked; + }); + }); + + function updateImportButtonLabel() { + const checkedCount = document.querySelectorAll('.mapping-checkbox:checked').length; + const importButton = document.getElementById('import-selected-mappings-btn'); + importButton.textContent = `Import Selected (${checkedCount})`; + importButton.disabled = checkedCount === 0; + } + + updateImportButtonLabel(); + + // Import button action + document.getElementById('import-selected-mappings-btn').addEventListener('click', function() { + const selectedMappings = []; + + document.querySelectorAll('.mapping-checkbox:checked').forEach(checkbox => { + const index = parseInt(checkbox.dataset.index); + const select = document.querySelector(`.target-node-select[data-index="${index}"]`); + const targetNodeId = select.value; + + if (targetNodeId && index < importMapping.mappings.length) { + selectedMappings.push({ + mapping: importMapping.mappings[index], + targetNodeId: targetNodeId + }); + } + }); + + if (selectedMappings.length > 0) { + let importCount = 0; + + selectedMappings.forEach(item => { + const targetNodeId = item.targetNodeId; + const notes = item.mapping.notes; + const targetNode = importMapping.existingNodes[targetNodeId].node; + + if (targetNode) { + targetNode.notes = targetNode.notes || []; + + const notesToImport = notes.filter(note => + typeof note === 'object' && note !== null && note.text); + + if (targetNode.title) { + notesToImport.forEach(note => { + note.nodeTitle = targetNode.title; + }); + } + + targetNode.notes = [...targetNode.notes, ...notesToImport]; + nodeNotes[targetNodeId] = [...targetNode.notes]; + + targetNode.flags = targetNode.flags || {}; + targetNode.flags.hasNotes = true; + + importCount++; + } + }); + + modalContainer.remove(); + app.graph.setDirtyCanvas(true); + + const totalImportCount = importCount; + if (totalImportCount > 0) { + alert(`Successfully imported notes for ${totalImportCount} nodes.`); + openGlobalNotesPanel(); + + if (app.graph) { + app.graph.change(); + } + } else { + alert("No notes were imported."); + } + } else { + alert("No nodes selected for importing notes."); + } + }); +} + \ No newline at end of file